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 42317178a865..c0ae3451dc72 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -441,9 +441,15 @@ jobs: if .regionalRehomeProtocol == null then "regionalRehomeProtocol" else empty end ] | if length > 0 then "runtime predecessor normalized legacy fields=" + join(",") else empty end' \ <<< "${CURRENT_RUNTIME}" - CURRENT_DIRECTOR_STATUS="$(admin_post current-cell-status \ + if ! CURRENT_DIRECTOR_STATUS="$(admin_post current-cell-status \ "${DIRECTOR_ORIGIN}/v1/admin/cell-status" \ - "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")" + "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"; then + if jq -e '.error == "cell_in_reserve_mode"' \ + "${RUNNER_TEMP}/current-cell-status.json" >/dev/null 2>&1; then + echo "${TARGET_CELL_ID} is in reserve mode: flip it to admitMode=db with the cell-flags workflow and wait for it to record db before rolling it, then flip it back to reserve" >&2 + fi + exit 1 + fi SOURCE_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \ <<< "${CURRENT_DIRECTOR_STATUS}")" if test "${ROLLBACK_RESUME}" = true && ! jq -e \ diff --git a/.github/workflows/cloud-operate-relay-production-cell-flags.yml b/.github/workflows/cloud-operate-relay-production-cell-flags.yml index c11fd5db52ca..769429a6abe8 100644 --- a/.github/workflows/cloud-operate-relay-production-cell-flags.yml +++ b/.github/workflows/cloud-operate-relay-production-cell-flags.yml @@ -32,7 +32,8 @@ on: Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, - reregisterInFlight + reregisterInFlight, phonePath (memory only takes effect in reserve; a reserve flip + must name phonePath) required: true type: string expected-generation: diff --git a/.github/workflows/cloud-operate-relay-production-reserve-watch.yml b/.github/workflows/cloud-operate-relay-production-reserve-watch.yml index d747843b7cac..2ee30fe5dbe0 100644 --- a/.github/workflows/cloud-operate-relay-production-reserve-watch.yml +++ b/.github/workflows/cloud-operate-relay-production-reserve-watch.yml @@ -25,8 +25,9 @@ on: type: string flipped-at: description: >- - When the reserve write applied, ISO with a zone, e.g. 2026-10-14T12:00:00Z. The non-drain - 503 background is the median of the 10 minutes before it + When the reserve (or phonePath=memory) write applied, ISO with a zone, e.g. + 2026-10-14T12:00:00Z. The non-drain 503 background and the phone rules' background are + the 10 minutes before it required: false default: '' type: string @@ -79,6 +80,8 @@ jobs: outputs: state: ${{ steps.outcome.outputs.state }} verdict: ${{ steps.outcome.outputs.verdict }} + # phonePath when a step-6 phone rule tripped; anything else flips the cell back whole. + flip: ${{ steps.outcome.outputs.flip }} env: DURATION_MINUTES: ${{ inputs.duration-minutes }} FLIPPED_AT: ${{ inputs.flipped-at }} @@ -89,6 +92,10 @@ jobs: set -euo pipefail [[ "${CELL_ID}" =~ ^production-gce-c[0-9]{1,3}$ ]] node -e "if (!/^[^.]+[.][^.]+[.][^.]+$/.test(process.env.ORCA_RELAY_ADMIN_ID_TOKEN ?? '')) process.exit(1)" + # A phone-rule trip writes phonePath=db mid-watch, through the flag tool. + ORCA_RELAY_CONTROL_ACCESS_TOKEN="$(gcloud auth print-access-token)" + echo "::add-mask::${ORCA_RELAY_CONTROL_ACCESS_TOKEN}" + export ORCA_RELAY_CONTROL_ACCESS_TOKEN node dev/scripts/watch-relay-reserve-cell.mjs watch \ --mode "${MODE}" --cell-id "${CELL_ID}" \ --cell-origin "https://${CELL_ID#production-gce-}.relay.onorca.dev" \ @@ -258,6 +265,7 @@ jobs: # lease wait can still kill the process before Postgres records db: the file says db, Postgres reserve. env: VERDICT: ${{ needs.watch.outputs.verdict || format('the watch ended {0} without a verdict', needs.watch.result) }} + FLIP: ${{ needs.watch.outputs.flip || 'admitMode' }} steps: - uses: actions/checkout@v4 if: ${{ always() }} @@ -297,7 +305,7 @@ jobs: --mode "${MODE}" --cell-id "${CELL_ID}" \ --cell-origin "https://${CELL_ID#production-gce-}.relay.onorca.dev" \ --project-id "${GCP_PROJECT_ID}" --director-origin "https://relay.onorca.dev" \ - --confirmation "${CONFIRMATION}" --verdict "${VERDICT}" \ + --confirmation "${CONFIRMATION}" --verdict "${VERDICT}" --flip "${FLIP}" \ | tee "${RUNNER_TEMP}/relay-reserve-flip-back.jsonl" # The flip-back line is logged only once the write (or its dry-run plan) finished; without @@ -308,12 +316,15 @@ jobs: if grep -qs '"event":"orca_relay_reserve_watch_flip_back"' "${RUNNER_TEMP}/relay-reserve-flip-back.jsonl"; then exit 0 fi - { - echo "## FLIP BACK FAILED: ${CELL_ID} may still be in reserve mode" - echo "Flip it to db by hand with Operate Relay Production Cell Flags." - } >> "${GITHUB_STEP_SUMMARY}" + if [[ "${FLIP}" == phonePath ]]; then + echo "## FLIP BACK FAILED: ${CELL_ID} may still have phonePath=memory" >> "${GITHUB_STEP_SUMMARY}" + echo "Set phonePath=default by hand with Operate Relay Production Cell Flags." >> "${GITHUB_STEP_SUMMARY}" + else + echo "## FLIP BACK FAILED: ${CELL_ID} may still be in reserve mode" >> "${GITHUB_STEP_SUMMARY}" + echo "Flip it to db by hand with Operate Relay Production Cell Flags." >> "${GITHUB_STEP_SUMMARY}" + fi node dev/scripts/watch-relay-reserve-cell.mjs alarm --kind flip-back-failed \ - --mode "${MODE}" --cell-id "${CELL_ID}" --project-id "${GCP_PROJECT_ID}" --verdict "${VERDICT}" + --mode "${MODE}" --cell-id "${CELL_ID}" --project-id "${GCP_PROJECT_ID}" --verdict "${VERDICT}" --flip "${FLIP}" - name: Audit summary if: ${{ always() }} diff --git a/.github/workflows/cloud-operate-relay-staging-cell-flags.yml b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml index 12781fd16a99..501d00faebc1 100644 --- a/.github/workflows/cloud-operate-relay-staging-cell-flags.yml +++ b/.github/workflows/cloud-operate-relay-staging-cell-flags.yml @@ -33,7 +33,8 @@ on: Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck, admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs, - reregisterInFlight + reregisterInFlight, phonePath (memory only takes effect in reserve; a reserve flip + must name phonePath) required: true type: string expected-generation: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c93bf235a48d..dd78c63edd40 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,7 +55,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version-file: docs/site/package.json # setup-node probes the store from the repository root, not this app. package-manager-cache: false @@ -227,7 +227,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 22 + node-version-file: docs/site/package.json package-manager-cache: false - name: Install site dependencies diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a926dc0fa11d..1484b36460d6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,6 +33,12 @@ on: required: false type: string default: '' + # Why only the app builds read it: the orcad template build refuses a dark switch. + layout_owner: + description: Set to main to build with ORCA_LAYOUT_OWNER=main and record save-divergence reports + required: false + type: string + default: '' workflow_dispatch: inputs: ref: @@ -107,7 +113,12 @@ jobs: env: VITE_EXPOSE_STORE: 'true' ORCA_CI_MAIN_SOURCEMAPS: '0' - run: pnpm run build:electron-vite:parallel --mode e2e + LAYOUT_OWNER: ${{ inputs.layout_owner }} + run: | + if [ -n "$LAYOUT_OWNER" ]; then + export ORCA_LAYOUT_OWNER="$LAYOUT_OWNER" + fi + pnpm run build:electron-vite:parallel --mode e2e # The CLI emits into out/main too; start only after Electron finishes clearing that tree. - name: Build shared E2E CLI @@ -125,6 +136,11 @@ jobs: - wait: e2e-cli + # The switch-on specs skip, rather than fail, when the build left no marker. + - name: Check layout-owner build marker + if: inputs.layout_owner == 'main' + run: test -f out/main/ORCA_LAYOUT_OWNER_MAIN + - name: Check shared native runtime action id: native-runtime-action run: | @@ -325,6 +341,7 @@ jobs: - name: Run changed E2E specs env: TEST_FILES_JSON: ${{ inputs.test_files }} + LAYOUT_OWNER: ${{ inputs.layout_owner }} run: | if [ -f config/scripts/ci-e2e-job-selection.mjs ]; then printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs > "$RUNNER_TEMP/general-e2e-specs" @@ -347,12 +364,43 @@ jobs: fi E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1) E2E_PROJECT_ARGS=() + if [ -n "$LAYOUT_OWNER" ]; then + test -f out/main/ORCA_LAYOUT_OWNER_MAIN + mkdir -p layout-divergence + E2E_ENV+=(ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/window-specs.jsonl") + E2E_ENV+=(PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/window-specs-results.json") + E2E_PROJECT_ARGS+=('--reporter=list,json') + fi if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then E2E_PROJECT_ARGS+=(--project=electron-headful) fi xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env "${E2E_ENV[@]}" \ pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" + - name: Summarize layout save divergence + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + run: &summarize_layout_divergence | + # Either file names a run: a log with no report is unattributed, a report with no log is zero. + shopt -s nullglob + runs=(layout-divergence/*.jsonl layout-divergence/*-results.json) + for run in $(printf '%s\n' "${runs[@]%.jsonl}" | sed 's/-results\.json$//' | sort -u); do + node config/scripts/layout-save-divergence-summary.mjs "$run.jsonl" "$run-summary.json" "$run-results.json" + if [ -f "$run-results.json" ]; then + node config/scripts/ci-e2e-failure-summary.mjs "$run-results.json" + fi + done + + - name: Upload layout save divergence report + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: layout-divergence-changed${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }} + path: layout-divergence/ + retention-days: 14 + if-no-files-found: warn + - name: Upload Playwright traces if: failure() || cancelled() uses: actions/upload-artifact@v7 @@ -410,11 +458,18 @@ jobs: # The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node. # The template is what `orca serve`'s default selection materializes that slot from. - name: Build this runner's orcad slot and template + env: + LAYOUT_OWNER: ${{ inputs.layout_owner }} run: | slot="$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" pnpm build:orcad-prebuilds pnpm build:orcad-prebuilds --require-slots "$slot" - pnpm build:orcad + # The headless oracle's orcad host must hold the same layout owner as the Electron host. + if [ -n "$LAYOUT_OWNER" ]; then + ORCA_LAYOUT_OWNER="$LAYOUT_OWNER" pnpm build:orcad + else + pnpm build:orcad + fi node config/scripts/build-orcad-template.mjs --targets "$slot" - name: Switch serve hosts on one profile if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') @@ -431,7 +486,30 @@ jobs: env: SKIP_BUILD: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' - run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/workspace-layout-oracle-headless.spec.ts --project=electron-headless --workers=1 + LAYOUT_OWNER: ${{ inputs.layout_owner }} + run: | + REPORT_ARGS=() + if [ -n "$LAYOUT_OWNER" ]; then + test -f out/main/ORCA_LAYOUT_OWNER_MAIN + mkdir -p layout-divergence + export ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/headless-oracle.jsonl" + export PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/headless-oracle-results.json" + REPORT_ARGS=('--reporter=list,json') + fi + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/workspace-layout-oracle-headless.spec.ts --project=electron-headless --workers=1 "${REPORT_ARGS[@]}" + - name: Summarize layout save divergence + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + run: *summarize_layout_divergence + - name: Upload layout save divergence report + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: layout-divergence-headless${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }} + path: layout-divergence/ + retention-days: 14 + if-no-files-found: warn - uses: actions/upload-artifact@v7 if: failure() with: @@ -486,9 +564,32 @@ jobs: ORCA_E2E_ORCAD_CONVERT_HOST: docker ORCA_E2E_FORWARD_APP_LOGS: '1' SHARD: ${{ matrix.shard }} + LAYOUT_OWNER: ${{ inputs.layout_owner }} + SHARD_INDEX: ${{ strategy.job-index }} run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad- --project=electron-headless --workers=1 --shard="$SHARD" + REPORT_ARGS=() + if [ -n "$LAYOUT_OWNER" ]; then + test -f out/main/ORCA_LAYOUT_OWNER_MAIN + mkdir -p layout-divergence + export ORCA_LAYOUT_SAVE_DIVERGENCE_LOG="$PWD/layout-divergence/managed-ssh-$SHARD_INDEX.jsonl" + export PLAYWRIGHT_JSON_OUTPUT_FILE="$PWD/layout-divergence/managed-ssh-$SHARD_INDEX-results.json" + REPORT_ARGS=('--reporter=list,json') + fi + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad- --project=electron-headless --workers=1 --shard="$SHARD" "${REPORT_ARGS[@]}" + - name: Summarize layout save divergence + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + run: *summarize_layout_divergence + - name: Upload layout save divergence report + if: always() && inputs.layout_owner == 'main' + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: layout-divergence-managed-ssh-${{ strategy.job-index }}${{ inputs.artifact_suffix }}-attempt-${{ github.run_attempt }} + path: layout-divergence/ + retention-days: 14 + if-no-files-found: warn - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b173efb769d7..65f32e880808 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -60,6 +60,7 @@ jobs: e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }} native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }} wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }} + layout_owner_switch_on: ${{ steps.e2e_filter.outputs.layout_owner_switch_on }} steps: - name: Checkout uses: actions/checkout@v6 @@ -84,6 +85,7 @@ jobs: /config/scripts/pr-code-change-scope.mjs /config/scripts/pr-package-test-paths.mjs /config/scripts/pr-layout-oracle-route.mjs + /config/scripts/pr-layout-owner-switch-on-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs @@ -164,6 +166,8 @@ jobs: WSL_CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)" WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" + LAYOUT_OWNER_SWITCH_ON="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-layout-owner-switch-on-route.mjs)" + echo "layout_owner_switch_on=$LAYOUT_OWNER_SWITCH_ON" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow "${E2E_SCOPE_ARGS[@]}")" if [ "$SHOULD_RUN" = true ]; then @@ -393,10 +397,6 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: bun config/scripts/check-layout-writer-ratchets.mjs - - name: Enforce deprecated runtime-owner importer ratchet - if: needs.code_paths.outputs.static_analysis == 'true' - run: node config/scripts/check-deprecated-runtime-owner-ratchet.mjs - - name: Enforce path-literal ratchet if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/check-path-literal-ratchet.mjs @@ -413,6 +413,10 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/check-legacy-relay-fence-ratchet.mjs + - name: Enforce unproven PTY retirement ratchet + if: needs.code_paths.outputs.static_analysis == 'true' + run: node config/scripts/check-unproven-pty-retirement-ratchet.mjs + - name: Check Node runtime pin if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/check-node-runtime-pin.mjs @@ -1536,6 +1540,28 @@ jobs: needs_build: true artifact_suffix: -layout-oracle + # Why not in verify's needs: the layout-owner switch is off in shipped builds, and some of these + # specs fail with it on until that work lands. It reports a red X without blocking the merge. + layout_owner_switch_on: + name: layout owner (switch on) + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.layout_owner_switch_on == 'true' && + needs.preflight.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/e2e.yml + with: + ref: ${{ github.sha }} + # LAYOUT_OWNER_SWITCH_ON_E2E_SPECS in config/scripts/pr-layout-owner-switch-on-route.mjs. + test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/terminal-cold-activation-deferral.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/ssh-orcad-"]' + run_changed_e2e: true + needs_build: true + artifact_suffix: -layout-owner-switch-on + layout_owner: main + # Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability # is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It # reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no diff --git a/cloud/apps/relay-ops/src/environment-config.test.ts b/cloud/apps/relay-ops/src/environment-config.test.ts index 321167d7cc8a..f3522a300cd3 100644 --- a/cloud/apps/relay-ops/src/environment-config.test.ts +++ b/cloud/apps/relay-ops/src/environment-config.test.ts @@ -64,6 +64,7 @@ function terraformCells(environment: 'production' | 'staging'): Array<{ machineType: string capacityRequests: number databasePoolMax: number + connectionHardCap: number | null }> { const tfvars = readFileSync( fileURLToPath(new URL(`../../../infra/terraform/environments/${environment}.tfvars`, import.meta.url)), @@ -77,7 +78,10 @@ function terraformCells(environment: 'production' | 'staging'): Array<{ zone: /zone\s*=\s*"([^"]+)"/.exec(match[2] ?? '')?.[1] ?? '', machineType: /machine_type\s*=\s*"([^"]+)"/.exec(match[2] ?? '')?.[1] ?? '', capacityRequests: Number(/capacity_requests\s*=\s*(\d+)/.exec(match[2] ?? '')?.[1]), - databasePoolMax: Number(/database_pool_max\s*=\s*(\d+)/.exec(match[2] ?? '')?.[1] ?? 10) + databasePoolMax: Number(/database_pool_max\s*=\s*(\d+)/.exec(match[2] ?? '')?.[1] ?? 10), + connectionHardCap: ((raw) => (raw === undefined ? null : Number(raw)))( + /connection_hard_cap\s*=\s*(\d+)/.exec(match[2] ?? '')?.[1] + ) })) .sort((left, right) => cellOrdinal(left.cellId) - cellOrdinal(right.cellId)) } @@ -97,7 +101,8 @@ describe('relay operations environment config', () => { zone: cell.zone, machineType: cell.machineType, capacityRequests: cell.capacityRequests, - databasePoolMax: cell.databasePoolMax + databasePoolMax: cell.databasePoolMax, + connectionHardCap: cell.connectionHardCap })) ).toEqual(expected) } @@ -122,17 +127,20 @@ describe('relay operations environment config', () => { { cellId: 'production-gce-c27', hostname: 'c27', origin: 'https://c27.relay.onorca.dev', region: 'asia-east2', zone: 'asia-east2-a', machineType: 'e2-standard-4', - capacityRequests: 6000, databasePoolMax: 10, configuredAdmission: false + capacityRequests: 6000, databasePoolMax: 10, connectionHardCap: null, + configuredAdmission: false }, { cellId: 'production-gce-c28', hostname: 'c28', origin: 'https://c28.relay.onorca.dev', region: 'asia-east2', zone: 'asia-east2-b', machineType: 'e2-standard-4', - capacityRequests: 6000, databasePoolMax: 10, configuredAdmission: false + capacityRequests: 6000, databasePoolMax: 10, connectionHardCap: null, + configuredAdmission: false }, { cellId: 'production-gce-c29', hostname: 'c29', origin: 'https://c29.relay.onorca.dev', region: 'asia-east2', zone: 'asia-east2-c', machineType: 'e2-standard-4', - capacityRequests: 6000, databasePoolMax: 10, configuredAdmission: false + capacityRequests: 6000, databasePoolMax: 10, connectionHardCap: null, + configuredAdmission: false } ]) diff --git a/cloud/apps/relay-ops/src/environment-config.ts b/cloud/apps/relay-ops/src/environment-config.ts index da3de7765edf..efd890367c8a 100644 --- a/cloud/apps/relay-ops/src/environment-config.ts +++ b/cloud/apps/relay-ops/src/environment-config.ts @@ -14,6 +14,8 @@ export type RelayOpsCellConfig = { machineType: RelayOpsMachineType capacityRequests: number databasePoolMax: number + // The cell's rendered connectionHardCap; null when Terraform leaves it unset. + connectionHardCap: number | null configuredAdmission: boolean } @@ -74,6 +76,9 @@ export function relayOpsCellsFromTerraform(input: { required(body, /\bcapacity_requests\s*=\s*(\d+)/, `${cellId} capacity`) ), databasePoolMax: Number(/\bdatabase_pool_max\s*=\s*(\d+)/.exec(body)?.[1] ?? 10), + connectionHardCap: ((raw) => (raw === undefined ? null : Number(raw)))( + /\bconnection_hard_cap\s*=\s*(\d+)/.exec(body)?.[1] + ), configuredAdmission: configuredAdmission === undefined || configuredAdmission === 'true' } }) diff --git a/cloud/apps/relay-ops/src/incident-monitor-sources.test.ts b/cloud/apps/relay-ops/src/incident-monitor-sources.test.ts index 32319df7f759..704f55717fa2 100644 --- a/cloud/apps/relay-ops/src/incident-monitor-sources.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor-sources.test.ts @@ -543,6 +543,92 @@ describe('incident monitor sources', () => { expect(waits).toEqual([]) }) + // Why: on 2026-10-19 cell-status refused reserve production-gce-c7 with a 404 on every sample. + it('marks a reserve cell unprobed and skips its refused migration reads', async () => { + const gcloud: GcloudClient = { + accessToken: async () => 'unused', + identityToken: async () => 'unused' + } + const reserveCellId = 'production-gce-c7' + const selector = { + generation: 1, + membership: { + existingOnly: [], + migrationOnly: ['production-gce-c2'], + general: productionCells.filter((cellId) => cellId !== 'production-gce-c2') + } + } + const waits: number[] = [] + const fetchImpl: typeof fetch = async (input, init) => { + const path = new URL(String(input)).pathname + const body = JSON.parse(String(init?.body)) as Record + if (path === '/v1/admin/admission-selector/status') return Response.json({ selector }) + if (path === '/v1/admin/cell-status') { + if (body.cellId === reserveCellId) { + return Response.json({ error: 'cell_in_reserve_mode' }, { status: 404 }) + } + return Response.json({ + status: { + enabled: true, + connectionCapacity: { hardCap: 600 }, + runtime: { lastHeartbeatAt: now - 1_000, heartbeatFresh: true } + } + }) + } + expect(path).toBe('/v1/admin/evacuation-status') + expect(body.sourceCellId).not.toBe(reserveCellId) + return Response.json({ + blocked: 0, + blockedExpiredUnregistered: 0, + registeredTargetInactive: 0 + }) + } + const result = await directorSignals('production', selector, gcloud, now, fetchImpl, async (ms) => { + waits.push(ms) + }) + expect(waits).toEqual([]) + expect(result.cells.find((cell) => cell.cellId === reserveCellId)).toMatchObject({ + reserve: true + }) + expect(result.cells.find((cell) => cell.cellId === 'production-gce-c1')).not.toHaveProperty( + 'reserve' + ) + const signals = result.source.signals + expect(signals[`cell.${reserveCellId}.admission_state`]).toMatchObject({ value: 2 }) + // Why: 10-19 run 39225932592 froze reserve c7 at 731 connections against the 500 default. + const configuredCap = RELAY_OPS_ENVIRONMENTS.production.cells.find( + (cell) => cell.cellId === reserveCellId + )?.connectionHardCap + expect(configuredCap).toBe(1_000) + expect(signals[`cell.${reserveCellId}.connection_hard_cap`]).toMatchObject({ + value: configuredCap + }) + for (const suffix of ['heartbeat_fresh', 'heartbeat_age_ms', 'migration_blocked']) { + expect(signals[`cell.${reserveCellId}.${suffix}`]).toBeUndefined() + } + expect(signals['cell.production-gce-c1.heartbeat_age_ms']).toMatchObject({ value: 1_000 }) + }) + + it('still fails a cell-status 404 that is not the reserve refusal', async () => { + const gcloud: GcloudClient = { + accessToken: async () => 'unused', + identityToken: async () => 'unused' + } + const selector = { + generation: 1, + membership: { existingOnly: [], migrationOnly: [], general: productionCells } + } + const collect = (error: string) => + directorSignals('production', selector, gcloud, now, async (input) => { + const path = new URL(String(input)).pathname + if (path === '/v1/admin/admission-selector/status') return Response.json({ selector }) + return Response.json({ error }, { status: 404 }) + }, async () => {}) + for (const error of ['cell_not_found', 'cell_in_reserve_mode_soon', '']) { + await expect(collect(error)).rejects.toThrow('Relay admin telemetry returned 404') + } + }) + // A 404 the director means (wrong role) must not be retried either. it('does not retry a director-only 404', async () => { const gcloud: GcloudClient = { diff --git a/cloud/apps/relay-ops/src/incident-monitor-sources.ts b/cloud/apps/relay-ops/src/incident-monitor-sources.ts index 9e041940af7f..f69d57b11bdc 100644 --- a/cloud/apps/relay-ops/src/incident-monitor-sources.ts +++ b/cloud/apps/relay-ops/src/incident-monitor-sources.ts @@ -452,10 +452,7 @@ function transientFetchFailure(error: unknown): boolean { return name === 'AbortError' || name === 'TimeoutError' } -function retryableAdminStatus(status: number, body: string): boolean { - if (ADMIN_RETRYABLE_STATUSES.has(status)) return true - // 401/403/400/413 and a 404 for 'director_only' are decisions, not weather. - if (status !== 404) return false +function adminErrorMessage(body: string): string | null { const parsed = AdminErrorBodySchema.safeParse( ((): unknown => { try { @@ -465,12 +462,27 @@ function retryableAdminStatus(status: number, body: string): boolean { } })() ) + return parsed.success ? parsed.data.error : null +} + +function retryableAdminStatus(status: number, body: string): boolean { + if (ADMIN_RETRYABLE_STATUSES.has(status)) return true + // 401/403/400/413 and a 404 for 'director_only' are decisions, not weather. + if (status !== 404) return false + const message = adminErrorMessage(body) return ( - parsed.success && - ADMIN_TRANSIENT_ERROR_MESSAGES.some((message) => parsed.data.error.includes(message)) + message !== null && + ADMIN_TRANSIENT_ERROR_MESSAGES.some((transient) => message.includes(transient)) ) } +// Step 5: cell-status refuses a reserve cell with exactly this 404 body; any other 404 stays a failure. +class AdminReserveCellRefusal extends Error { + constructor(status: number) { + super(`Relay admin telemetry returned ${status}`) + } +} + async function adminPost( fetchImpl: typeof fetch, origin: string, @@ -502,6 +514,9 @@ async function adminPost( // Status only: the body carries a director error message, which must not // reach the log the way an identity would. const errorBody = await response.text().catch(() => '') + if (response.status === 404 && adminErrorMessage(errorBody) === 'cell_in_reserve_mode') { + throw new AdminReserveCellRefusal(response.status) + } if (lastAttempt || !retryableAdminStatus(response.status, errorBody)) { throw new Error(`Relay admin telemetry returned ${response.status}`) } @@ -545,23 +560,32 @@ export async function directorSignals( } const statuses: Array<{ cell: RelayOpsEnvironment['cells'][number] - status: z.infer['status'] + status: ({ reserve: false } & z.infer['status']) | { reserve: true } }> = [] for (const cell of environment.cells) { + let response: unknown + try { + response = await adminPost( + fetchImpl, + environment.directorOrigin, + token, + '/v1/admin/cell-status', + { v: 1, cellId: cell.cellId }, + wait + ) + } catch (error) { + if (!(error instanceof AdminReserveCellRefusal)) throw error + statuses.push({ cell, status: { reserve: true } }) + continue + } statuses.push({ cell, - status: CellStatusSchema.parse( - await adminPost( - fetchImpl, - environment.directorOrigin, - token, - '/v1/admin/cell-status', - { v: 1, cellId: cell.cellId }, - wait - ) - ).status + status: { reserve: false, ...CellStatusSchema.parse(response).status } }) } + const reserveCellIds = new Set( + statuses.filter(({ status }) => status.reserve).map(({ cell }) => cell.cellId) + ) const migrationEntries: Array<{ sourceCellId: string migration: z.infer @@ -570,6 +594,8 @@ export async function directorSignals( for (const source of environment.cells) { for (const target of environment.cells) { if (source.cellId === target.cellId || !migrationTargets.has(target.cellId)) continue + // The director refuses migration reads, and every migration, on a reserve cell. + if (reserveCellIds.has(source.cellId)) continue migrationEntries.push({ sourceCellId: source.cellId, migration: MigrationStatusSchema.parse( @@ -601,6 +627,24 @@ export async function directorSignals( const signals: Record = {} for (const { cell, status } of statuses) { const prefix = `cell.${cell.cellId}` + if (status.reserve) { + // cell-status carries the live cap; unset in Terraform keeps the evaluator's default, as there. + if (cell.connectionHardCap !== null) { + addSignal(signals, `${prefix}.connection_hard_cap`, cell.connectionHardCap, nowAt) + } + // Legacy admission needs relay_cells.enabled, which only cell-status reads: left missing. + if (selector.generation !== 0) { + addSignal( + signals, + `${prefix}.admission_state`, + ['existing-only', 'migration-only', 'general'].indexOf( + selectorCellState(selector, cell.cellId) + ), + nowAt + ) + } + continue + } const admissionState = effectiveAdmissionState( selector, status.enabled, @@ -645,12 +689,13 @@ export async function directorSignals( return { source: { observedAt: nowAt, signals }, selector, - cells: statuses.map(({ cell }) => ({ + cells: statuses.map(({ cell, status }) => ({ cellId: cell.cellId, region: cell.region, runtimeKnown: true, powered: true, - expectedAdmissionState: selectorCellState(expectedSelector, cell.cellId) + expectedAdmissionState: selectorCellState(expectedSelector, cell.cellId), + ...(status.reserve ? { reserve: true } : {}) })) } } diff --git a/cloud/apps/relay-ops/src/incident-monitor.test.ts b/cloud/apps/relay-ops/src/incident-monitor.test.ts index db1d8797d1c3..a0eadd1b1636 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.test.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.test.ts @@ -643,6 +643,71 @@ describe('incident monitor evaluator', () => { expect.objectContaining({ code: 'selector_mismatch' }) ) }) + + // Why: the director refuses cell-status and migration reads on a reserve cell (2026-10-19, c7). + it('judges a reserve cell on probes, logs and selector, without its refused reads', () => { + const reserveSample = (): IncidentSample => { + const sample = healthySample() + sample.cells[0] = { ...sample.cells[0]!, reserve: true } + const admin = sample.sources['director-admin']!.signals + delete admin['cell.production-gce-c1.heartbeat_fresh'] + delete admin['cell.production-gce-c1.heartbeat_age_ms'] + delete admin['cell.production-gce-c1.migration_blocked'] + delete admin['cell.production-gce-c1.migration_target_inactive'] + return sample + } + expect(evaluateIncidentSample(reserveSample(), startedAt)).toMatchObject({ + status: 'green', + failures: [] + }) + + const crowded = reserveSample() + crowded.sources['relay-logs']!.signals['cell.production-gce-c1.queued_bytes'] = signal( + INCIDENT_MONITOR_THRESHOLDS.cellQueuedBytes + 1 + ) + expect(evaluateIncidentSample(crowded, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'cell.production-gce-c1.queued_bytes' }) + ) + + // The connections ceiling is never skipped: the collector's configured cap, else the default. + const busy = reserveSample() + busy.sources['relay-logs']!.signals['cell.production-gce-c1.connections'] = signal(731) + expect(evaluateIncidentSample(busy, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'cell.production-gce-c1.connections', threshold: 500 }) + ) + busy.sources['director-admin']!.signals['cell.production-gce-c1.connection_hard_cap'] = + signal(1_000) + expect(evaluateIncidentSample(busy, startedAt).status).toBe('green') + busy.sources['relay-logs']!.signals['cell.production-gce-c1.connections'] = signal(1_000) + expect(evaluateIncidentSample(busy, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'cell.production-gce-c1.connections', threshold: 999 }) + ) + + const unready = reserveSample() + unready.sources['active-probe']!.signals['cell.production-gce-c1.ready'] = signal(0) + expect(evaluateIncidentSample(unready, startedAt).failures).toContainEqual( + expect.objectContaining({ signal: 'cell.production-gce-c1.ready' }) + ) + + const unknownAdmission = reserveSample() + delete unknownAdmission.sources['director-admin']!.signals[ + 'cell.production-gce-c1.admission_state' + ] + expect(evaluateIncidentSample(unknownAdmission, startedAt).failures).toContainEqual({ + code: 'signal_missing', + source: 'director-admin', + signal: 'cell.production-gce-c1.admission_state' + }) + + const notReserve = reserveSample() + notReserve.cells[0] = { ...notReserve.cells[0]!, reserve: false } + expect(evaluateIncidentSample(notReserve, startedAt).failures).toContainEqual( + expect.objectContaining({ + code: 'signal_missing', + signal: 'cell.production-gce-c1.heartbeat_fresh' + }) + ) + }) }) describe('incident monitor lifecycle', () => { diff --git a/cloud/apps/relay-ops/src/incident-monitor.ts b/cloud/apps/relay-ops/src/incident-monitor.ts index 94c7a4791498..bcb3c30bf092 100644 --- a/cloud/apps/relay-ops/src/incident-monitor.ts +++ b/cloud/apps/relay-ops/src/incident-monitor.ts @@ -182,6 +182,8 @@ export type IncidentCellExpectation = { runtimeKnown: boolean powered: boolean expectedAdmissionState: AdmissionState + // Step 5 reserve mode: the director's cell-status and migration reads refuse the cell. + reserve?: boolean } export type IncidentSample = { @@ -558,6 +560,15 @@ function checkCell( ] ] as const for (const [source, signals, signalName, threshold, comparison] of checks) { + // Probes, logs and selector still judge a reserve cell; the reserve watch reads its runtime. + if ( + cell.reserve && + ['heartbeat_fresh', 'heartbeat_age_ms', 'migration_blocked', 'migration_target_inactive'].some( + (suffix) => signalName.endsWith(suffix) + ) + ) { + continue + } if ( migrationPolicy === 'recover-forward' && cell.cellId === recoverySourceCellId && diff --git a/cloud/apps/relay/src/activity-lease-reader-census.test.ts b/cloud/apps/relay/src/activity-lease-reader-census.test.ts new file mode 100644 index 000000000000..b8bc4aadc202 --- /dev/null +++ b/cloud/apps/relay/src/activity-lease-reader-census.test.ts @@ -0,0 +1,182 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' + +// Step 6 (design ยง3.5): a phone on a `phonePath=memory` cell writes no `confirmation:` or +// `splice:` lease. Every reader of the lease table is classified by why that is safe; a new +// reader fails this test until someone decides. While the cell is recorded as reserve its +// control holds no lease either, so step 5's guards already cover it; after a flip back a +// memory phone looks like today's phone once its 90 s lease has expired. +const ACTIVITY_LEASE_READERS: Record = { + // Reads control leases only, so phone leases never change the answer. + 'assignment-store.ts:activateControlOnce': 'control-only', + 'assignment-store.ts:reconcileIdleRegionalRehome': 'control-only', + 'assignment-store.ts:renewTransactionalControlActivity': 'control-only', + 'assignment-store.ts:startRegionalRehomeCandidate': 'control-only', + 'control-renewal-statement.ts:CONTROL_RENEWAL_BATCH_SQL': 'control-only', + 'idle-regional-rehome-selection.ts:selectIdleRegionalRehomes': 'control-only', + // Refuses or skips a cell recorded as reserve (or an unreadable set). + 'assignment-store.ts:cellDeploymentStatus': 'refuses-or-skips-reserve-cells', + 'assignment-store.ts:cellEvacuationCapacity': 'refuses-or-skips-reserve-cells', + 'assignment-store.ts:releaseExpiredActivity': 'refuses-or-skips-reserve-cells', + 'assignment-store.ts:releaseExpiredActivityLeases': 'refuses-or-skips-reserve-cells', + 'assignment-store.ts:startEvacuation': 'refuses-or-skips-reserve-cells', + // Its only caller drops candidates whose source or target is a reserve cell. + 'idle-regional-rehome-selection.ts:sourceRequestUnits': 'refuses-or-skips-reserve-cells', + // Only hosts in an open drain, migration or rehome, which no reserve cell can have: + // setCellAdmitMode refuses reserve while one is open, and every start refuses a reserve cell. + 'assignment-store.ts:activeCellMigrations': 'open-flow-only', + 'assignment-store.ts:additionalActivityCellIds': 'open-flow-only', + 'assignment-store.ts:assertCellReservationAccounting': 'open-flow-only', + 'assignment-store.ts:beginCellDrainSend': 'open-flow-only', + 'assignment-store.ts:beginCellDrainSendOnce': 'open-flow-only', + 'assignment-store.ts:completeReadyEvacuations': 'open-flow-only', + 'assignment-store.ts:completeReadyRegionalRehomes': 'open-flow-only', + 'assignment-store.ts:reconcileDrainMigrationAccounting': 'open-flow-only', + 'assignment-store.ts:reconcileReservationAccounting': 'open-flow-only', + 'assignment-store.ts:refreshDrainMigrationLeasesOnce': 'open-flow-only', + 'registered-migration-abandonment.ts:ABANDONED_REGISTERED_MIGRATION': 'open-flow-only', + // One host's rows under lock; each caller is classified in the reserve-mode census. + 'assignment-store.ts:lockAssignmentActivities': 'host-lock', + // Metrics and previews: they report, and act on nothing. + 'assignment-inventory-snapshot.ts:readAssignmentInventorySnapshot': 'report-only', + 'region-correction-preview.ts:previewRegionalRehomeEligibility': 'report-only', + 'registered-migration-inventory.ts:readRegisteredMigrationInventory': 'report-only', + // Step 5's existing-only stranded release, fixed separately in #30609 before the first flip. + 'assignment-store.ts:assignmentStrandedOnUnservedCell': 'step-5-stranded-release' +} + +// A read is FROM, JOIN or USING on the table; `DELETE FROM` alone only writes. Not caught: a +// second read inside an already listed function, and counters derived from leases elsewhere. +const LEASE_TABLE = 'relay_assignment_activity_leases' +// Bare table-name values that never reach SQL. +const BARE_TABLE_NAME_USES: Record = { + 'database.ts:POSTGRES_TRANSACTION_PHASES': 'labels a failed statement by the table it names' +} +const LEASE_READ = /(? { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + it('classifies every reader of the activity-lease table', () => { + const { readers, bareTableNames } = activityLeaseReaders() + // A bare name could reach `FROM ${table}`, which the scan cannot see: write the table into the SQL. + expect(bareTableNames.filter((use) => !(use in BARE_TABLE_NAME_USES))).toEqual([]) + expect(readers.filter((name) => !(name in ACTIVITY_LEASE_READERS))).toEqual([]) + expect(Object.keys(ACTIVITY_LEASE_READERS).filter((name) => !readers.includes(name))).toEqual([]) + }) + + async function setup(now: () => number) { + database = await openInMemoryRelayDatabase() + const store = new RelayAssignmentStore(database, now) + await store.reconcileCells(CELLS) + return store + } + + // A host whose source cell kept no lease for it, as a memory cell's control and phones do. + async function registeredEvacuationWithoutSourceLeases(store: RelayAssignmentStore) { + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + expect(first.cellId).toBe('cell-a') + const migration = await store.startEvacuation(identity, 'cell-b') + await store.activateControl(identity, { + cellId: 'cell-b', + assignmentEpoch: migration.assignmentEpoch, + generation: 1 + }) + await store.markMigrationTargetRegistered(identity, { + cellId: 'cell-b', + assignmentEpoch: migration.assignmentEpoch + }) + await store.releaseActivity(identity, `control-pending:${first.assignmentEpoch}`) + return { identity, migration } + } + + it('completes no evacuation from a reserve source, though it holds no source lease', async () => { + const now = () => 1_000 + const store = await setup(now) + const { identity, migration } = await registeredEvacuationWithoutSourceLeases(store) + // setCellAdmitMode refuses this with the migration open; written directly to reach the second guard. + await database!.query( + `INSERT INTO relay_cell_admit_modes (cell_id, admit_mode, updated_at) VALUES ('cell-a', 'reserve', 1000)` + ) + const fresh = new RelayAssignmentStore(database!, now) + await expect(fresh.completeEvacuation(identity, migration.assignmentEpoch)).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect(fresh.completeReadyEvacuations()).rejects.toThrow('cell_in_reserve_mode') + expect( + await database!.query(`SELECT completed_at FROM relay_assignment_migrations`) + ).toEqual([{ completed_at: null }]) + // The same host on a db source completes: the guard, not the fixture, held it. + await store.setCellAdmitMode('cell-a', 'db') + expect(await store.completeReadyEvacuations()).toBe(1) + }) + + it('refuses evacuation status for a reserve cell, where activeCellMigrations would count no units', async () => { + const store = await setup(() => 1_000) + await store.setCellAdmitMode('cell-a', 'reserve') + await expect(store.cellEvacuationStatus('cell-a', 'cell-b', true)).rejects.toThrow( + 'cell_in_reserve_mode' + ) + await expect(store.cellEvacuationStatus('cell-b', 'cell-a', false)).rejects.toThrow( + 'cell_in_reserve_mode' + ) + }) +}) + +function activityLeaseReaders(): { readers: string[]; bareTableNames: string[] } { + const directory = fileURLToPath(new URL('.', import.meta.url)) + const readers = new Set() + const bareTableNames: string[] = [] + const files = readdirSync(directory, { recursive: true, encoding: 'utf8' }).filter( + (file) => file.endsWith('.ts') && !file.includes('.test.') && !file.split(/[\\/]/).includes('test-fixtures') + ) + for (const file of files) { + const text = readFileSync(join(directory, file), 'utf8') + if (!text.includes(LEASE_TABLE)) continue + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true) + const visit = (node: ts.Node): void => { + if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node) || ts.isStringLiteral(node)) { + if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && node.text.trim() === LEASE_TABLE) { + bareTableNames.push(`${file}:${enclosingName(node)}`) + } + if (LEASE_READ.test(node.getText())) { + readers.add(`${file}:${enclosingName(node)}`) + return + } + } + ts.forEachChild(node, visit) + } + visit(source) + } + return { readers: [...readers].sort(), bareTableNames } +} + +function enclosingName(node: ts.Node): string { + for (let current = node.parent; current; current = current.parent) { + if ((ts.isMethodDeclaration(current) || ts.isFunctionDeclaration(current)) && current.name) { + return current.name.getText() + } + // A module-level SQL constant, never a local `const rows = ...` inside a method. + if (ts.isVariableStatement(current) && ts.isSourceFile(current.parent)) { + return current.declarationList.declarations.map((declaration) => declaration.name.getText()).join(',') + } + } + return '' +} diff --git a/cloud/apps/relay/src/app.ts b/cloud/apps/relay/src/app.ts index a9d6c370c168..21910a8ca2a6 100644 --- a/cloud/apps/relay/src/app.ts +++ b/cloud/apps/relay/src/app.ts @@ -143,6 +143,7 @@ export function createRelayApp( cellReserverPoll?: () => void cellAdmitModeEffective?: () => 'db' | 'reserve' cellAdmitModeRaw?: () => 'db' | 'reserve' + cellPhonePathEffective?: () => 'db' | 'memory' cellReserveCounts?: () => { bookings: number intake: { perSec: number; burst: number; tokens: number } @@ -891,6 +892,9 @@ export function createRelayApp( ...(operations.cellAdmitModeEffective ? { admitModeEffective: operations.cellAdmitModeEffective() } : {}), + ...(operations.cellPhonePathEffective + ? { phonePathEffective: operations.cellPhonePathEffective() } + : {}), // Placement as built, not as configured: `on` only when this director books reserve cells. ...(config.role === 'director' ? { reservePlacement: operations.reservePlacement?.placementMode ?? 'off' } diff --git a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts index a10b49940132..077552c8f03c 100644 --- a/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts +++ b/cloud/apps/relay/src/assignment-reserve-mode-census.test.ts @@ -228,6 +228,27 @@ describe('reserve-mode census', () => { await restore() }) + it('keeps a connected host pinned when its reserve cell goes existing-only after a ledger write', async () => { + let now = 1_000 + const store = await setup(() => now) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + const first = await store.assign(identity) + await setReserve(store, [first.cellId]) + // The reserve ledger stamps last_activity_at and the cell keeps no activity rows. + now += 5 * 60_000 + await database!.query('DELETE FROM relay_assignment_activity_leases WHERE user_id = ?', [identity.userId]) + await database!.query('UPDATE relay_assignments SET last_activity_at = ? WHERE user_id = ?', [now, identity.userId]) + await store.setCellAdmissionState(first.cellId, 'existing-only') + now += 2 * 60_000 + expect(await store.assign(identity)).toMatchObject({ + cellId: first.cellId, + assignmentEpoch: first.assignmentEpoch + }) + const { unknown, restore } = await unknownSet(() => now) + expect((await unknown.assign(identity)).assignmentEpoch).toBe(first.assignmentEpoch) + await restore() + }) + // Only the first reserve-set read is held; it is released (or failed) by the test. function holdFirstRead(options: { fail?: boolean } = {}) { const query = database!.query.bind(database!) diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index 66d90177bdac..53dfcb5598f2 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1290,6 +1290,11 @@ export class RelayAssignmentStore { if (state !== 'existing-only') { return false } + // A reserve cell keeps no activity rows and its ledger stamps last_activity_at, so zero + // leases proves nothing; a dead one is left to the dead-reserve path and its fence. + if (await this.mayBeReserveCell(text(existing, 'cell_id'), transaction)) { + return false + } const liveLeases = ( await transaction.query( `SELECT COUNT(*) AS live FROM relay_assignment_activity_leases diff --git a/cloud/apps/relay/src/cell-flags.ts b/cloud/apps/relay/src/cell-flags.ts index ccf22204d591..a3c3e08bcc8f 100644 --- a/cloud/apps/relay/src/cell-flags.ts +++ b/cloud/apps/relay/src/cell-flags.ts @@ -19,6 +19,8 @@ export type CellFlags = { reserveDryRun: boolean // Flip-back leases in flight at once; absent is a third of the pool. Speeds up one cell. reregisterInFlight?: number + // Step 6: `memory` resumes paired phones from one device read; it acts only in reserve mode. + phonePath: 'db' | 'memory' } export const CELL_FLAG_DEFAULTS: CellFlags = { @@ -26,7 +28,8 @@ export const CELL_FLAG_DEFAULTS: CellFlags = { ticketCheck: 'off', rejectionFence: true, admitMode: 'db', - reserveDryRun: false + reserveDryRun: false, + phonePath: 'db' } export const READ_TIMEOUT_MARGIN_MIN_MS = 1_000 @@ -52,7 +55,8 @@ const CellFlagsSchema = z.object({ admitMode: z.enum(['db', 'reserve']).default(CELL_FLAG_DEFAULTS.admitMode), intakePerSec: z.number().positive().max(1_000).optional(), reserveDryRun: z.boolean().default(CELL_FLAG_DEFAULTS.reserveDryRun), - reregisterInFlight: z.number().int().min(1).max(16).optional() + reregisterInFlight: z.number().int().min(1).max(16).optional(), + phonePath: z.enum(['db', 'memory']).default(CELL_FLAG_DEFAULTS.phonePath) }) const KNOWN_FLAG_KEYS = new Set(Object.keys(CellFlagsSchema.shape)) diff --git a/cloud/apps/relay/src/cell-phone-path.blackbox.test.ts b/cloud/apps/relay/src/cell-phone-path.blackbox.test.ts new file mode 100644 index 000000000000..c8526367dde8 --- /dev/null +++ b/cloud/apps/relay/src/cell-phone-path.blackbox.test.ts @@ -0,0 +1,455 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { reserveDeadManWindowMs } from './cell-reserve-dead-man.js' +import { openRelayDatabase } from './database.js' +import { nextFrame, RESERVE_MODE_CELL_ID, startReserveModeCell } from './test-fixtures/reserve-mode-cell.js' +import { StatementRecordingDatabase } from './test-fixtures/statement-recording-database.js' + +// Step 6: a reserve-mode cell with phonePath=memory resumes paired phones without the database. + +// Phone credentials are 32 random bytes. +const token = (fill: number) => Buffer.alloc(32, fill).toString('base64url') +const TOKEN = token(1) +const OTHER_CELL = { id: 'production-gce-c4', url: 'https://c4.relay.onorca.dev', capacityRequests: 900 } + +// A paired phone's accept and attach with the switch off, as main issued them at 9ea54343710. +const DATABASE_PATH_ACCEPT = [ + 'SELECT * FROM relay_devices WHERE relay_host_id = ? AND (current_hash = ? OR grace_hash = ?)', + 'SELECT assignment.*, cell.cell_url, region.region FROM relay_assignments assignment JOIN relay_cells cell ON cell.cell_id = assignment.cell_id LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id WHERE assignment.user_id = ? AND assignment.relay_host_id = ?', + 'BEGIN', + 'SELECT * FROM relay_invites WHERE relay_host_id = ? AND token_hash = ?', + 'SELECT * FROM relay_devices WHERE relay_host_id = ? AND (current_hash = ? OR grace_hash = ?)', + 'COMMIT', + 'BEGIN', + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ? [locked]', + 'SELECT * FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id ASC [locked]', + 'UPDATE relay_assignments SET pending_confirmations = CASE WHEN pending_confirmations + ? < 0 THEN 0 ELSE pending_confirmations + ? END, lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?', + 'INSERT INTO relay_assignment_activity_leases (user_id, relay_host_id, activity_id, activity_kind, cell_id, request_units, expires_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + 'UPDATE relay_cells SET reserved_requests = CASE WHEN reserved_requests + ? < 0 THEN 0 ELSE reserved_requests + ? END, updated_at = ? WHERE cell_id = ? AND (? <= 0 OR reserved_requests + ? <= capacity_requests) RETURNING cell_id', + 'COMMIT', + 'BEGIN', + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ? [locked]', + 'SELECT * FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id ASC [locked]', + 'UPDATE relay_assignments SET reserved_splices = CASE WHEN reserved_splices + ? < 0 THEN 0 ELSE reserved_splices + ? END, lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?', + 'INSERT INTO relay_assignment_activity_leases (user_id, relay_host_id, activity_id, activity_kind, cell_id, request_units, expires_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + 'UPDATE relay_cells SET reserved_requests = CASE WHEN reserved_requests + ? < 0 THEN 0 ELSE reserved_requests + ? END, updated_at = ? WHERE cell_id = ? AND (? <= 0 OR reserved_requests + ? <= capacity_requests) RETURNING cell_id', + 'COMMIT', + 'INSERT INTO relay_connection_bases (basis_conn_id, user_id, relay_host_id, relay_device_id, owning_control_generation, credential_kind, invite_token_hash, accepted_credential_version, accepted_as, deadline, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' +] + +const CONFIRM_DEVICE_READ = + 'SELECT * FROM relay_devices WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ?' + +// The flag-off confirm, as main issued it at 9ea54343710. +const DATABASE_PATH_CONFIRM = [ + 'BEGIN', + 'SELECT basis_conn_id, result_json FROM relay_confirm_results WHERE user_id = ? AND relay_host_id = ? AND req_id = ?', + 'SELECT * FROM relay_connection_bases WHERE basis_conn_id = ?', + 'UPDATE relay_devices SET updated_at = updated_at WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ?', + 'SELECT * FROM relay_devices WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ?', + 'UPDATE relay_devices SET current_expires_at = ?, updated_at = ? WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ?', + 'INSERT INTO relay_confirm_results (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, relay_device_id, detail_json) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'COMMIT', + 'BEGIN', + 'SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ? [locked]', + 'SELECT * FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id ASC [locked]', + 'DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ? AND activity_id = ?', + 'UPDATE relay_assignments SET pending_confirmations = CASE WHEN pending_confirmations + ? < 0 THEN 0 ELSE pending_confirmations + ? END, lease_expires_at = CASE WHEN lease_expires_at > ? THEN lease_expires_at ELSE ? END, last_activity_at = ? WHERE user_id = ? AND relay_host_id = ?', + 'UPDATE relay_cells SET reserved_requests = CASE WHEN reserved_requests + ? < 0 THEN 0 ELSE reserved_requests + ? END, updated_at = ? WHERE cell_id = ? AND (? <= 0 OR reserved_requests + ? <= capacity_requests) RETURNING cell_id', + 'COMMIT' +] + +async function until(check: () => Promise, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs + while (!(await check())) { + if (Date.now() > deadline) throw new Error('condition not reached') + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +describe('the memory phone path', () => { + const cleanup: Array<() => Promise | void> = [] + + afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + }) + + async function cell(now?: () => number) { + vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-phone-path-')) + cleanup.push(() => rmSync(dataDir, { recursive: true, force: true })) + const inner = await openRelayDatabase({ dataDir }) + cleanup.push(() => inner.close()) + const database = new StatementRecordingDatabase(inner) + const reserve = await startReserveModeCell({ database, dataDir, cleanup, ...(now ? { now } : {}) }) + // The admit-effective writer ticks on its own; every other statement is the phone's. + const phoneStatements = () => + database.statements.filter((sql) => !sql.includes('relay_cell_admit_effective')) + // The test's own reads and writes go around the recorder. + return { ...reserve, database, raw: inner, phoneStatements } + } + + async function seatedPhoneHost(phonePath: 'db' | 'memory') { + const reserve = await cell() + reserve.setAdmitMode('reserve', { phonePath }) + const seated = await reserve.bookedHost(3) + await reserve.installDevice(seated.identity.hostId, TOKEN) + reserve.database.statements.length = 0 + return { reserve, seated } + } + + // A phone resumed and spliced to its desktop, waiting to confirm. + async function splicedPhone( + phonePath: 'db' | 'memory', + device: { expiresAt?: number } = {}, + clock: { now?: () => number; beforeDial?: () => void } = {} + ) { + const reserve = await cell(clock.now) + reserve.setAdmitMode('reserve', { phonePath }) + const seated = await reserve.bookedHost(3) + await reserve.installDevice(seated.identity.hostId, TOKEN, device) + clock.beforeDial?.() + const connOpen = nextFrame(seated.socket, 'conn-open') + const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) + const basisConnId = String((await connOpen).connId) + await reserve.attachData(await connOpen, seated.ack.generation) + expect(await phone.hello).toMatchObject({ ok: true }) + reserve.database.statements.length = 0 + // The desktop's confirm, sent in reply to the phone over the end-to-end channel. + const confirm = async (reqId: string, basis = basisConnId) => { + const answer = Promise.race([ + nextFrame(seated.socket, 'device-resume-confirmed'), + nextFrame(seated.socket, 'control-error') + ]) + seated.socket.send(JSON.stringify({ type: 'device-resume-confirm', v: 1, reqId, basisConnId: basis })) + return await answer + } + return { reserve, seated, phone, basisConnId, confirm } + } + + const deviceRow = async (reserve: Awaited>) => + (await reserve.raw.query(`SELECT current_expires_at FROM relay_devices`))[0] + + const warnings = (event: string) => + vi + .mocked(console.warn) + .mock.calls.map(([line]) => String(line)) + .filter((line) => line.includes(event)) + .map((line) => JSON.parse(line)) + + it('acts only while the cell is in reserve mode, so admitMode=db or a dead-man trip turns it off', async () => { + let now = Date.now() + const reserve = await cell(() => now) + expect(reserve.relay.sessions.phonePathEffective()).toBe('db') + reserve.setAdmitMode('reserve', { phonePath: 'memory' }) + expect(reserve.relay.sessions.phonePathEffective()).toBe('memory') + reserve.setAdmitMode('db', { phonePath: 'memory' }) + expect(reserve.relay.sessions.phonePathEffective()).toBe('db') + reserve.setAdmitMode('reserve', { phonePath: 'memory' }) + expect(reserve.relay.sessions.phonePathEffective()).toBe('memory') + now += reserveDeadManWindowMs(reserve.config.cellId) + 1_000 + expect(reserve.relay.sessions.inReserveMode()).toBe(false) + expect(reserve.relay.sessions.phonePathEffective()).toBe('db') + }) + + it('admits a seated phone resume from one device read and writes no activity lease', async () => { + const { reserve, seated } = await seatedPhoneHost('memory') + const connOpen = nextFrame(seated.socket, 'conn-open') + const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) + expect(await connOpen).toMatchObject({ kind: 'resume', relayDeviceId: 'device-1' }) + expect(reserve.phoneStatements()).toEqual([DATABASE_PATH_ACCEPT[0]]) + await reserve.attachData(await connOpen, seated.ack.generation) + expect(await phone.hello).toMatchObject({ type: 'relay-hello', ok: true, credentialKind: 'resume' }) + expect(reserve.phoneStatements()).toEqual([DATABASE_PATH_ACCEPT[0]]) + expect(await reserve.activityLeases()).toEqual([]) + }) + + it('keeps the database path statement for statement while phonePath is db', async () => { + const { reserve, seated } = await seatedPhoneHost('db') + const connOpen = nextFrame(seated.socket, 'conn-open') + const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) + await reserve.attachData(await connOpen, seated.ack.generation) + expect(await phone.hello).toMatchObject({ type: 'relay-hello', ok: true }) + expect(reserve.phoneStatements()).toEqual(DATABASE_PATH_ACCEPT) + }) + + it('closes a revoked or expired device 4401 and never tries it as an invite', async () => { + const reserve = await cell() + reserve.setAdmitMode('reserve', { phonePath: 'memory' }) + const seated = await reserve.bookedHost(3) + await reserve.installDevice(seated.identity.hostId, token(2), { revokedAt: Date.now() - 1_000 }) + await reserve.installDevice(seated.identity.hostId, token(3), { + relayDeviceId: 'device-2', + expiresAt: Date.now() - 1_000 + }) + for (const refused of [token(2), token(3)]) { + reserve.database.statements.length = 0 + const phone = await reserve.dialPhone(seated.identity.hostId, refused) + expect(await phone.hello).toEqual({ type: 'relay-hello', ok: false, code: 4401 }) + expect((await phone.closed).code).toBe(4401) + expect(reserve.phoneStatements()).toEqual([DATABASE_PATH_ACCEPT[0]]) + } + }) + + it('closes 4503 when the device read fails, without trying the invite path', async () => { + const { reserve, seated } = await seatedPhoneHost('memory') + vi.spyOn(reserve.relay.store, 'lookupResume').mockRejectedValue(new Error('connection terminated')) + const invite = vi.spyOn(reserve.relay.store, 'resolveInviteForMove') + for (let dial = 0; dial < 2; dial += 1) { + const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) + expect((await phone.closed).code).toBe(4503) + } + expect(invite).not.toHaveBeenCalled() + expect(reserve.phoneStatements()).toEqual([]) + // One line opens the window; the repeat waits for its closing line. + expect(warnings('orca_relay_client_accept_db_error')).toEqual([ + { event: 'orca_relay_client_accept_db_error', cause: 'error', cellId: reserve.config.cellId } + ]) + }) + + it('falls through to the invite path only when no device row matches', async () => { + const { reserve, seated } = await seatedPhoneHost('memory') + const created = nextFrame(seated.socket, 'invite-created') + seated.socket.send(JSON.stringify({ type: 'invite-create', reqId: 'invite-1', relayDeviceId: 'device-9' })) + const invite = await created + const connOpen = nextFrame(seated.socket, 'conn-open') + await reserve.dialPhone(seated.identity.hostId, String(invite.inviteToken)) + expect(await connOpen).toMatchObject({ kind: 'invite', relayDeviceId: 'device-9' }) + // Invites keep today's leases: install validates their basis in Postgres. + expect((await reserve.activityLeases()).map((lease) => lease.activity_kind)).toEqual(['invite', 'invite']) + }) + + it('answers a phone whose host has no seat here from one assignment read', async () => { + const reserve = await cell() + reserve.setAdmitMode('reserve', { phonePath: 'memory' }) + await reserve.relay.assignments.reconcileCells([...reserve.config.cells, OTHER_CELL]) + const dial = async (row: 'elsewhere' | 'here' | 'none') => { + const host = await reserve.host() + if (row !== 'none') await reserve.insertRow(host.hostId, 3, row === 'here' ? reserve.config.cellId : OTHER_CELL.id) + await reserve.installDevice(host.hostId, TOKEN) + reserve.database.statements.length = 0 + const phone = await reserve.dialPhone(host.hostId, TOKEN) + const closed = await phone.closed + expect(reserve.phoneStatements()).toEqual(DATABASE_PATH_ACCEPT.slice(0, 2)) + return closed.code + } + expect(await dial('elsewhere')).toBe(4409) + expect(await dial('none')).toBe(4404) + expect(warnings('orca_relay_client_miss_row_names_here')).toEqual([]) + expect(await dial('here')).toBe(4404) + expect(warnings('orca_relay_client_miss_row_names_here')).toEqual([ + expect.objectContaining({ cellId: reserve.config.cellId, relayHostIdDigest: expect.any(String) }) + ]) + vi.spyOn(reserve.relay.assignments, 'resolve').mockRejectedValueOnce(new Error('connection terminated')) + const host = await reserve.host() + await reserve.installDevice(host.hostId, TOKEN) + expect((await (await reserve.dialPhone(host.hostId, TOKEN)).closed).code).toBe(4409) + expect(warnings('orca_relay_client_miss_read_error')).toEqual([ + { event: 'orca_relay_client_miss_read_error', cause: 'error', cellId: reserve.config.cellId } + ]) + }) + + it('takes the database path for a phone whose switch turned off during its device read', async () => { + const { reserve, seated } = await seatedPhoneHost('memory') + const lookup = reserve.relay.store.lookupResume.bind(reserve.relay.store) + vi.spyOn(reserve.relay.store, 'lookupResume').mockImplementation(async (...args) => { + const found = await lookup(...args) + reserve.setAdmitMode('reserve', { phonePath: 'db' }) + return found + }) + const connOpen = nextFrame(seated.socket, 'conn-open') + await reserve.dialPhone(seated.identity.hostId, TOKEN) + expect(await connOpen).toMatchObject({ kind: 'resume' }) + expect((await reserve.activityLeases()).map((lease) => lease.activity_kind)).toEqual(['confirmation']) + }) + + it('confirms from the memory basis with one fresh read, renewing a device due for it', async () => { + const { reserve, confirm } = await splicedPhone('memory') + const before = Number((await deviceRow(reserve))?.current_expires_at) + const answer = await confirm('confirm-1') + expect(answer).toMatchObject({ type: 'device-resume-confirmed', reqId: 'confirm-1', renewed: true, acceptedAs: 'current' }) + const after = Number((await deviceRow(reserve))?.current_expires_at) + expect(after).toBeGreaterThan(before) + expect(answer.resumeExpiresAt).toBe(after) + expect(reserve.phoneStatements()).toEqual([ + CONFIRM_DEVICE_READ, + expect.stringMatching(/^UPDATE relay_devices SET current_expires_at = \?.*RETURNING current_expires_at$/) + ]) + expect(await reserve.activityLeases()).toEqual([]) + // Its audit row leaves with the next batch. + await reserve.relay.store.resumeAudit.flush() + const audits = await reserve.raw.query( + `SELECT detail_json FROM relay_audit_events WHERE type = 'resume-confirmed'` + ) + expect(audits.map((row) => JSON.parse(String(row.detail_json)))).toEqual([ + { reqId: 'confirm-1', basisConnId: expect.any(String), renewed: true } + ]) + // A retry of the same request answers from memory; another basis cannot reuse its reqId. + reserve.database.statements.length = 0 + expect(await confirm('confirm-1')).toEqual(answer) + expect(reserve.phoneStatements()).toEqual([]) + expect(await confirm('confirm-1', 'another-basis')).toMatchObject({ + type: 'control-error', + code: 'confirmation_tuple_mismatch' + }) + }) + + it('answers renewed:true with the stored expiry when renewal is not due, or its write fails', async () => { + const fresh = Date.now() + 29.5 * 24 * 60 * 60 * 1000 + const notDue = await splicedPhone('memory', { expiresAt: fresh }) + expect(await notDue.confirm('confirm-1')).toMatchObject({ renewed: true, resumeExpiresAt: fresh }) + expect(notDue.reserve.phoneStatements()).toEqual([CONFIRM_DEVICE_READ]) + + const due = Date.now() + 20 * 24 * 60 * 60 * 1000 + const failing = await splicedPhone('memory', { expiresAt: due }) + failing.reserve.database.failing = (sql) => sql.startsWith('UPDATE relay_devices') + expect(await failing.confirm('confirm-1')).toMatchObject({ renewed: true, resumeExpiresAt: due }) + expect(Number((await deviceRow(failing.reserve))?.current_expires_at)).toBe(due) + // Never silent: a renewal left failing ages phones toward re-pairing. + expect(warnings('orca_relay_resume_renewal_failed')).toEqual([ + { event: 'orca_relay_resume_renewal_failed', cause: 'error', cellId: failing.reserve.config.cellId } + ]) + }) + + it('confirms from memory after the switch turned off between accept and confirm, taking no lease', async () => { + const { reserve, confirm } = await splicedPhone('memory') + reserve.setAdmitMode('reserve', { phonePath: 'db' }) + expect(await confirm('confirm-1')).toMatchObject({ type: 'device-resume-confirmed', renewed: true }) + expect(reserve.phoneStatements()[0]).toBe(CONFIRM_DEVICE_READ) + expect(await reserve.activityLeases()).toEqual([]) + }) + + it('replays a stored answer after its splice closed, and drops the basis itself', async () => { + const { reserve, phone, confirm } = await splicedPhone('memory') + const answer = await confirm('confirm-1') + phone.socket.close() + await phone.closed + await new Promise((resolve) => setTimeout(resolve, 50)) + reserve.database.statements.length = 0 + expect(await confirm('confirm-1')).toEqual(answer) + expect(await confirm('confirm-1', 'another-basis')).toMatchObject({ code: 'confirmation_tuple_mismatch' }) + expect(reserve.phoneStatements()).toEqual([]) + // A new request on the closed basis finds none in memory, so it goes to the database. + expect(await confirm('confirm-2')).toMatchObject({ code: 'confirmation_not_active' }) + expect(reserve.phoneStatements()[0]).toBe('BEGIN') + }) + + it('answers a grace-version confirm renewed:false with its expiry unchanged, as today', async () => { + const { reserve, confirm } = await splicedPhone('memory') + const stored = Number((await deviceRow(reserve))?.current_expires_at) + // An install rotated the credential after the accept read, so the accepted version is grace. + await reserve.raw.query( + `UPDATE relay_devices SET grace_hash = current_hash, grace_version = 1, grace_expires_at = ?, + current_hash = 'rotated', current_version = 2`, + [Date.now() + 60_000] + ) + expect(await confirm('confirm-1')).toMatchObject({ + type: 'device-resume-confirmed', + currentVersion: 2, + acceptedAs: 'current', + renewed: false, + resumeExpiresAt: stored + }) + expect(reserve.phoneStatements()).toEqual([CONFIRM_DEVICE_READ]) + }) + + it('answers every device change since the accept read as the database confirm does', async () => { + const changes: Record = { + revoked: ['UPDATE relay_devices SET revoked_at = ?', [Date.now() - 1]], + expired: ['UPDATE relay_devices SET current_expires_at = ?', [Date.now() - 1]], + retired: ["UPDATE relay_devices SET current_hash = 'rotated-twice', current_version = 3, grace_version = 2", []], + unchanged: ['UPDATE relay_devices SET updated_at = updated_at', []] + } + const outcome = async (phonePath: 'db' | 'memory', [sql, params]: [string, unknown[]]) => { + const { reserve, confirm } = await splicedPhone(phonePath) + await reserve.raw.query(sql, params) + const answer = await confirm('confirm-1') + return answer.type === 'control-error' ? answer.code : `${answer.type}:${answer.acceptedAs}:${answer.renewed}` + } + for (const [name, change] of Object.entries(changes)) { + expect(await outcome('memory', change), name).toBe(await outcome('db', change)) + } + expect(await outcome('memory', changes.revoked!)).toBe('reject-revoked') + }) + + it('sends a confirm with no memory basis to the database, which holds none either', async () => { + const { reserve, phone, confirm } = await splicedPhone('memory') + phone.socket.close() + await phone.closed + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(await confirm('confirm-1')).toMatchObject({ type: 'control-error', code: 'confirmation_not_active' }) + expect(reserve.phoneStatements()[0]).toBe('BEGIN') + }) + + it('keeps the database confirm statement for statement while phonePath is db', async () => { + const { reserve, confirm } = await splicedPhone('db') + expect(await confirm('confirm-1')).toMatchObject({ type: 'device-resume-confirmed', renewed: true }) + expect(reserve.phoneStatements()).toEqual(DATABASE_PATH_CONFIRM) + }) + + describe('rolling a memory cell back', () => { + // Flip back by the switch, or by the dead-man; either way the confirm finishes from memory. + type Cell = Awaited> + type Advance = (ms: number) => void + type Rollback = { beforeDial?: (advance: Advance) => void; roll: (reserve: Cell, advance: Advance) => void } + // The dead-man trips just after the phone's accept: its window ran from the host's booking. + const window = reserveDeadManWindowMs(RESERVE_MODE_CELL_ID) + const rollbacks: Record = { + 'admitMode=db': { roll: (reserve) => reserve.setAdmitMode('db', { phonePath: 'memory' }) }, + 'phonePath=db': { roll: (reserve) => reserve.setAdmitMode('reserve', { phonePath: 'db' }) }, + 'a dead-man trip': { beforeDial: (advance) => advance(window - 5_000), roll: (_reserve, advance) => advance(6_000) } + } + + for (const [name, rollback] of Object.entries(rollbacks)) { + it(`finishes an in-flight confirm on its memory basis after ${name}, writing and leaking no lease`, async () => { + let now = Date.now() + const advance: Advance = (ms) => { + now += ms + } + const spliced = await splicedPhone('memory', {}, { + now: () => now, + beforeDial: () => rollback.beforeDial?.(advance) + }) + const { reserve, seated } = spliced + // The flag tool records reserve in Postgres before the cell admits from memory. + await reserve.relay.assignments.reconcileCells([...reserve.config.cells, OTHER_CELL]) + await reserve.relay.assignments.setCellAdmitMode(reserve.config.cellId, 'reserve') + rollback.roll(reserve, advance) + expect(reserve.relay.sessions.phonePathEffective()).toBe('db') + reserve.database.statements.length = 0 + expect(await spliced.confirm('confirm-1')).toMatchObject({ type: 'device-resume-confirmed', renewed: true }) + expect(reserve.phoneStatements().filter((sql) => !sql.includes('relay_assignment'))).toEqual([ + CONFIRM_DEVICE_READ, + expect.stringMatching(/^UPDATE relay_devices SET current_expires_at/) + ]) + // Only the control lease a flip back re-registers; nothing for the phone. + const leases = async () => (await reserve.activityLeases()).map((lease) => lease.activity_kind) + expect((await leases()).filter((kind) => kind !== 'control')).toEqual([]) + // Postgres still records reserve, so evacuation leaves the cell alone. + await expect( + reserve.relay.assignments.startEvacuation( + { userId: 'user-1', relayHostId: seated.identity.hostId }, + OTHER_CELL.id + ) + ).rejects.toThrow('cell_in_reserve_mode') + // The splice still runs, and closing it releases nothing it never took. + expect(spliced.phone.socket.readyState).toBe(spliced.phone.socket.OPEN) + // Let a flip back finish leasing its control (and fold its relay_cells write) first. + await until(async () => reserve.relay.sessions.reregistrationPending() === 0) + await new Promise((resolve) => setTimeout(resolve, 250)) + reserve.database.statements.length = 0 + spliced.phone.socket.close() + await spliced.phone.closed + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(reserve.phoneStatements()).toEqual([]) + expect((await leases()).filter((kind) => kind !== 'control')).toEqual([]) + }) + } + }) +}) diff --git a/cloud/apps/relay/src/cell-seat-feed.test.ts b/cloud/apps/relay/src/cell-seat-feed.test.ts index 487f6356709e..1945af12b8cc 100644 --- a/cloud/apps/relay/src/cell-seat-feed.test.ts +++ b/cloud/apps/relay/src/cell-seat-feed.test.ts @@ -72,7 +72,8 @@ const SeatFeedReplySchema = z.object({ ticketCheck: z.enum(['off', 'shadow', 'enforce']), rejectionFence: z.boolean(), admitMode: z.enum(['db', 'reserve']), - reserveDryRun: z.boolean() + reserveDryRun: z.boolean(), + phonePath: z.enum(['db', 'memory']) }), ignoredKeys: z.array(z.string()).optional() }) @@ -200,6 +201,7 @@ function createCell(relayConfig = config(), cellFlags?: () => AppliedControlFlag cellIncarnation: incarnation, cellSeatFeed: (sinceSeq) => registry.seatFeed(sinceSeq), cellFlags, + cellPhonePathEffective: () => registry.phonePathEffective(), isDraining: () => registry.isDraining(), runtimeCounts: () => ({ totalConnections: 0, @@ -349,6 +351,8 @@ describe('cell seat feed', () => { }) expect(await runtime.json()).toMatchObject({ flagsApplied: applied, + // Not reserve mode, so db whatever the switch says. + phonePathEffective: 'db', databasePoolMax: expect.any(Number), // What the flag tool checks a write against before it writes. supportedFlags: { @@ -359,7 +363,8 @@ describe('cell seat feed', () => { admitMode: { type: 'enum', values: ['db', 'reserve'] }, intakePerSec: { type: 'number', max: 1_000 }, reserveDryRun: { type: 'boolean' }, - reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true } + reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true }, + phonePath: { type: 'enum', values: ['db', 'memory'] } } }) }) diff --git a/cloud/apps/relay/src/credential-store.ts b/cloud/apps/relay/src/credential-store.ts index 8d17ba623f39..58e9ae3c47aa 100644 --- a/cloud/apps/relay/src/credential-store.ts +++ b/cloud/apps/relay/src/credential-store.ts @@ -7,6 +7,7 @@ import { } from '@orca-cloud/relay-contract' import type { RelayDatabase, SqlRow } from './database.js' import { HeapWindowReaper } from './heap-window-reaper.js' +import { ResumeConfirmedAuditBuffer } from './resume-confirmed-audit-buffer.js' const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000 // Released desktops validate invite expiry against their own clock with zero @@ -95,6 +96,64 @@ function string(row: SqlRow, field: string): string { return value } +// Memory confirms renew a device at most this often (design decision 3: at most once a day of use). +export const RESUME_RENEWAL_INTERVAL_MS = 24 * 60 * 60 * 1000 + +function tokenFamily(device: SqlRow) { + return { + currentVersion: number(device, 'current_version'), + currentHash: string(device, 'current_hash'), + currentExpiresAt: number(device, 'current_expires_at'), + graceVersion: optionalNumber(device, 'grace_version'), + graceHash: typeof device.grace_hash === 'string' ? device.grace_hash : undefined, + graceExpiresAt: optionalNumber(device, 'grace_expires_at'), + revokedAt: optionalNumber(device, 'revoked_at') + } +} + +// A device row's answer to a resume token: null when revoked, or neither hash is still valid. +function resumeReservation( + device: SqlRow, + relayHostId: string, + tokenHash: string, + reservationId: string, + now: number +): CredentialReservation | null { + if (optionalNumber(device, 'revoked_at') !== undefined) return null + const currentHash = string(device, 'current_hash') + const currentExpiresAt = number(device, 'current_expires_at') + const graceHash = device.grace_hash + const graceExpiresAt = optionalNumber(device, 'grace_expires_at') + let acceptedAs: 'current' | 'grace' + let acceptedCredentialVersion: number + if (equalHash(currentHash, tokenHash) && currentExpiresAt > now) { + acceptedAs = 'current' + acceptedCredentialVersion = number(device, 'current_version') + } else if ( + typeof graceHash === 'string' && + equalHash(graceHash, tokenHash) && + (graceExpiresAt ?? 0) > now + ) { + acceptedAs = 'grace' + acceptedCredentialVersion = number(device, 'grace_version') + } else { + return null + } + return { + userId: string(device, 'user_id'), + relayHostId, + relayDeviceId: string(device, 'relay_device_id'), + credentialKind: 'resume', + tokenHash, + reservationId, + leaseExpiresAt: now + RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs, + acceptedCredentialVersion, + acceptedAs, + resumeExpiresAt: currentExpiresAt, + graceExpiresAt + } +} + export class RelayCredentialStore { // committed_at has no index, so a LIMIT delete would plan as a sequential scan of the whole table. private readonly confirmResultReaper = new HeapWindowReaper( @@ -103,10 +162,15 @@ export class RelayCredentialStore { CONFIRM_RESULT_REAP_BUDGET ) + // Only memory confirms add to it, so its flush timer never starts on a director. + readonly resumeAudit: ResumeConfirmedAuditBuffer + constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now - ) {} + ) { + this.resumeAudit = new ResumeConfirmedAuditBuffer(database, now) + } async createInvite(identity: RelayIdentity, relayDeviceId: string): Promise<{ inviteToken: string @@ -216,42 +280,33 @@ export class RelayCredentialStore { [relayHostId, tokenHash, tokenHash] ) const device = deviceRows[0] - if (!device || optionalNumber(device, 'revoked_at') !== undefined) return null - const currentHash = string(device, 'current_hash') - const currentExpiresAt = number(device, 'current_expires_at') - const graceHash = device.grace_hash - const graceExpiresAt = optionalNumber(device, 'grace_expires_at') - let acceptedAs: 'current' | 'grace' - let acceptedCredentialVersion: number - if (equalHash(currentHash, tokenHash) && currentExpiresAt > now) { - acceptedAs = 'current' - acceptedCredentialVersion = number(device, 'current_version') - } else if ( - typeof graceHash === 'string' && - equalHash(graceHash, tokenHash) && - (graceExpiresAt ?? 0) > now - ) { - acceptedAs = 'grace' - acceptedCredentialVersion = number(device, 'grace_version') - } else { - return null - } - return { - userId: string(device, 'user_id'), - relayHostId, - relayDeviceId: string(device, 'relay_device_id'), - credentialKind: 'resume', - tokenHash, - reservationId, - leaseExpiresAt: now + RELAY_PROTOCOL_LIMITS.hostAttachDeadlineMs, - acceptedCredentialVersion, - acceptedAs, - resumeExpiresAt: currentExpiresAt, - graceExpiresAt - } + return device ? resumeReservation(device, relayHostId, tokenHash, reservationId, now) : null }) } + // Step 6's one-read resume. Only `none` may fall through to the invite path: a device row + // that refuses this token closes the phone (4401), as reserveCredential would. + async lookupResume( + relayHostId: string, + token: string, + reservationId: string = randomUUID() + ): Promise< + | { outcome: 'none' } + | { outcome: 'refused' } + | { outcome: 'resume'; reservation: CredentialReservation } + > { + const tokenHash = hashCredential(token) + const rows = await this.database.query( + `SELECT * FROM relay_devices + WHERE relay_host_id = ? AND (current_hash = ? OR grace_hash = ?)`, + [relayHostId, tokenHash, tokenHash] + ) + const device = rows[0] + if (!device) return { outcome: 'none' } + const reservation = resumeReservation(device, relayHostId, tokenHash, reservationId, this.now()) + return reservation ? { outcome: 'resume', reservation } : { outcome: 'refused' } + } + async recordConnectionBasis(input: CredentialReservation & { basisConnId: string owningControlGeneration: number @@ -483,19 +538,7 @@ export class RelayCredentialStore { const device = devices[0] if (!device) throw new RelayStoreError('credential_not_found') const acceptedVersion = number(basis, 'accepted_credential_version') - const decision = decideResumeCommit( - { - currentVersion: number(device, 'current_version'), - currentHash: string(device, 'current_hash'), - currentExpiresAt: number(device, 'current_expires_at'), - graceVersion: optionalNumber(device, 'grace_version'), - graceHash: typeof device.grace_hash === 'string' ? device.grace_hash : undefined, - graceExpiresAt: optionalNumber(device, 'grace_expires_at'), - revokedAt: optionalNumber(device, 'revoked_at') - }, - acceptedVersion, - now - ) + const decision = decideResumeCommit(tokenFamily(device), acceptedVersion, now) if (decision.startsWith('reject-')) throw new RelayStoreError(decision) const renewed = decision === 'renew-current' const resumeExpiresAt = renewed @@ -547,6 +590,82 @@ export class RelayCredentialStore { }) } + // Step 6's confirm for a basis the cell holds in memory: one fresh device read, and a renewal + // write at most once a day per device. A current-version confirm always answers renewed:true + // with the expiry the database now holds, so a phone keeps scheduling its renewals. + async confirmMemoryResume( + input: RelayIdentity & { + reqId: string + basisConnId: string + relayDeviceId: string + acceptedCredentialVersion: number + acceptedAs: 'current' | 'grace' + }, + options: { onRenewalFailure?: (error: unknown) => void } = {} + ): Promise { + const now = this.now() + const devices = await this.database.query( + `SELECT * FROM relay_devices + WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ?`, + [input.userId, input.relayHostId, input.relayDeviceId] + ) + const device = devices[0] + if (!device) throw new RelayStoreError('credential_not_found') + const decision = decideResumeCommit(tokenFamily(device), input.acceptedCredentialVersion, now) + if (decision.startsWith('reject-')) throw new RelayStoreError(decision) + const renewed = decision === 'renew-current' + let resumeExpiresAt = number(device, 'current_expires_at') + const renewalDue = resumeExpiresAt < now + RELAY_PROTOCOL_LIMITS.resumeTtlMs - RESUME_RENEWAL_INTERVAL_MS + if (renewed && renewalDue) { + // A failing renewal still answers the expiry the database holds, but must stay visible: + // left failing, phones age toward re-pairing. + resumeExpiresAt = await this.renewResume(input, device, now).catch((error: unknown) => { + options.onRenewalFailure?.(error) + return resumeExpiresAt + }) + } + this.resumeAudit.add({ + userId: input.userId, + relayHostId: input.relayHostId, + relayDeviceId: input.relayDeviceId, + detail: { reqId: input.reqId, basisConnId: input.basisConnId, renewed } + }) + return { + v: 1, + reqId: input.reqId, + currentVersion: number(device, 'current_version'), + acceptedAs: input.acceptedAs, + renewed, + resumeExpiresAt, + ...(optionalNumber(device, 'grace_expires_at') === undefined + ? {} + : { graceExpiresAt: optionalNumber(device, 'grace_expires_at') }) + } + } + + // Guarded by the row just read, so a revoke or install that committed since leaves it alone; + // the answer then keeps the expiry that was read, which the database still holds. + private async renewResume( + identity: RelayIdentity & { relayDeviceId: string }, + device: SqlRow, + now: number + ): Promise { + const renewedTo = now + RELAY_PROTOCOL_LIMITS.resumeTtlMs + const rows = await this.database.query( + `UPDATE relay_devices SET current_expires_at = ?, updated_at = ? + WHERE user_id = ? AND relay_host_id = ? AND relay_device_id = ? + AND current_version = ? AND current_hash = ? AND revoked_at IS NULL + AND current_expires_at < ? + RETURNING current_expires_at`, + [ + renewedTo, now, identity.userId, identity.relayHostId, identity.relayDeviceId, + number(device, 'current_version'), string(device, 'current_hash'), + now + RELAY_PROTOCOL_LIMITS.resumeTtlMs - RESUME_RENEWAL_INTERVAL_MS + ] + ) + return rows[0] ? number(rows[0], 'current_expires_at') : number(device, 'current_expires_at') + } + async deactivateBasis(basisConnId: string): Promise { await this.database.query(`UPDATE relay_connection_bases SET active = ? WHERE basis_conn_id = ?`, [ 0, @@ -580,25 +699,9 @@ export class RelayCredentialStore { relayHostId: string, token: string ): Promise<{ userId: string; relayDeviceId: string } | null> { - const tokenHash = hashCredential(token) - const rows = await this.database.query( - `SELECT * FROM relay_devices - WHERE relay_host_id = ? AND (current_hash = ? OR grace_hash = ?)`, - [relayHostId, tokenHash, tokenHash] - ) - const device = rows[0] - if (!device || optionalNumber(device, 'revoked_at') !== undefined) return null - const now = this.now() - const currentValid = - equalHash(string(device, 'current_hash'), tokenHash) && - number(device, 'current_expires_at') > now - const graceHash = device.grace_hash - const graceValid = - typeof graceHash === 'string' && - equalHash(graceHash, tokenHash) && - (optionalNumber(device, 'grace_expires_at') ?? 0) > now - return currentValid || graceValid - ? { userId: string(device, 'user_id'), relayDeviceId: string(device, 'relay_device_id') } + const found = await this.lookupResume(relayHostId, token) + return found.outcome === 'resume' + ? { userId: found.reservation.userId, relayDeviceId: found.reservation.relayDeviceId } : null } diff --git a/cloud/apps/relay/src/floating-promise-census.test.ts b/cloud/apps/relay/src/floating-promise-census.test.ts index fcc056a54462..10574467484d 100644 --- a/cloud/apps/relay/src/floating-promise-census.test.ts +++ b/cloud/apps/relay/src/floating-promise-census.test.ts @@ -15,6 +15,7 @@ const NEVER_REJECTS = new Map([ ['cell-reserve-client.ts#send', 'one try/catch around the request; resolves every item'], ['cell-reserve-client.ts#demote', 'one try/catch around the request'], ['reserve-ledger-writer.ts#flush', 'one try/catch around the write; retries with backoff'], + ['resume-confirmed-audit-buffer.ts#flush', 'catches the insert; a failed batch goes back to the buffer'], ['control-renewal-batch.ts#flush', 'rejects the waiters, never itself'], ['regional-rehome-worker.ts#run', 'one try/catch around the whole poll'], ['relay-control-flag-channel.ts#poll', 'one catch on the whole read'] diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index 6bf30e9a60d9..d33c6e36578b 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -39,6 +39,8 @@ import { printableCloseReason } from './control-close-reason-text.js' import { ControlRenewalBatch } from './control-renewal-batch.js' import { RelayCredentialStore, type CredentialReservation } from './credential-store.js' import { HostCloseReasonMemory } from './host-close-reason-memory.js' +import { MemoryResumeConfirmations } from './memory-resume-confirmations.js' +import { PhoneAcceptLog } from './phone-accept-log.js' import { postProofCloseCause, PostProofCloseLog, @@ -132,6 +134,8 @@ type PendingConnection = { client: WebSocket attachTimer: ReturnType credentialActivityId: string | null + // `memory`: a resume admitted from the seat, which writes no activity leases. + phonePath: 'db' | 'memory' capacityReservation?: PendingHostDataReservation timing: ClientAcceptTiming } @@ -247,6 +251,8 @@ export type CellReserveAdmission = { directorContact?: () => void // The switch file's flip-back pace, when set. reregisterInFlight?: () => number | undefined + // The switch file's phone path; it acts only while the cell is in reserve mode. + phonePath?: () => 'db' | 'memory' // The pool-pressure shed's condition: a hello memory cannot admit is shed, not queued. databaseShedding: () => boolean book: CellReserveBook @@ -298,6 +304,8 @@ export class HostSessionRegistry { private readonly drainSentHosts = new Set() private readonly seatLog: CellSeatLog private readonly postProofCloses: PostProofCloseLog + private readonly phoneAccepts: PhoneAcceptLog + private readonly memoryConfirmations: MemoryResumeConfirmations // Seat key -> the demoted control socket. Its own set, not drainSentHosts: it ends with // that socket (close, rebind or a new join), so a later booking here is a fresh seat. private readonly demotedSeats = new Map() @@ -442,6 +450,10 @@ export class HostSessionRegistry { ) { this.seatLog = new CellSeatLog(undefined, now) this.postProofCloses = new PostProofCloseLog(observer) + this.phoneAccepts = new PhoneAcceptLog(config.cellId) + this.memoryConfirmations = new MemoryResumeConfirmations(store, now, (error) => + this.phoneAccepts.renewalFailed(isRelayDatabaseTransientError(error) ? 'database_transient' : 'error') + ) } // Reserve mode is only ever a cell's, and only while its applied switch says so. @@ -453,6 +465,12 @@ export class HostSessionRegistry { return this.reserveMode() } + // Step 6: the seat is authoritative only in reserve mode, so a dead-man trip or admitMode=db + // turns the memory phone path off for new phones too. + phonePathEffective(): 'db' | 'memory' { + return this.reserveMode() && this.reserveAdmission?.phonePath?.() === 'memory' ? 'memory' : 'db' + } + // Leaving reserve mode: every control admitted from memory gets the database lease it never // took, while it stays connected. Rescans until none lacks one, so a hello whose proof lands // after the flip is caught too. A control whose row never names this cell is closed (4409). @@ -833,34 +851,48 @@ export class HostSessionRegistry { stageMs[stage] = at - stageCursor stageCursor = at } - if (this.config.role === 'cell') { - // Each lookup is its own pooled round trip; stop between them once the phone - // has left instead of running the rest of the chain for nobody. - let outerIdentity = await this.store.resolveResume(hostId, credential) - if (abandonedByClient('assignment')) return - if (!outerIdentity) { - outerIdentity = await this.store.resolveInviteForMove(hostId, credential) - if (abandonedByClient('assignment')) return - } - const assignment = outerIdentity - ? await this.assignments.resolve({ userId: outerIdentity.userId, relayHostId: hostId }) - : null - if (!assignment || assignment.cellId !== this.config.cellId) { + // Step 6: a resume is admitted from one device read and the seat, writing no leases. + let memoryReservation: CredentialReservation | null = null + let memoryMissedDevice = false + if (this.config.role === 'cell' && this.phonePathEffective() === 'memory') { + markStage('assignment') + let found: Awaited> + try { + found = await this.store.lookupResume(hostId, credential) + } catch (error) { + // Never the invite path: an unread device is not a missing one. capacityReservation?.release() - this.observer.recordAuth(false) - this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + this.phoneAccepts.databaseError( + isRelayDatabaseTransientError(error) ? 'database_transient' : 'error' + ) + this.rejectClient(socket, RELAY_CLOSE_CODE.DRAINING) return } - if (abandonedByClient('assignment')) return - } - markStage('assignment') - const reservation = await this.store.reserveCredential(hostId, credential) - if (!reservation) { - capacityReservation?.release() - this.observer.recordAuth(false) - this.rejectClient(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) - return + if (abandonedByClient('credential')) return + // The switch turned off during the read: today's whole path, never a lease-less memory phone. + if (this.phonePathEffective() === 'memory') { + if (found.outcome === 'refused') { + capacityReservation?.release() + this.observer.recordAuth(false) + this.rejectClient(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) + return + } + if (found.outcome === 'resume') memoryReservation = found.reservation + else memoryMissedDevice = true + } } + const reservation = + memoryReservation ?? + (await this.reserveCredentialFromDatabase( + socket, + hostId, + credential, + memoryMissedDevice, + capacityReservation, + abandonedByClient, + markStage + )) + if (!reservation) return this.observer.recordAuth(true) if (abandonedByClient('credential', () => this.failReservationBestEffort(reservation))) return markStage('credential') @@ -879,6 +911,10 @@ export class HostSessionRegistry { session.socket.readyState !== session.socket.OPEN ) { capacityReservation?.release() + if (memoryReservation) { + await this.rejectMissedSeat(socket, reservation, sessionKey) + return + } await this.store.failReservation(reservation) // The only rejection that can name a cause: the host is genuinely absent. // The attach-deadline 4404 below fires while control is still connected. @@ -900,7 +936,7 @@ export class HostSessionRegistry { const connTicket = randomBytes(32).toString('base64url') const identity = { userId: reservation.userId, relayHostId: hostId } const credentialActivityId = - this.config.role === 'cell' + this.config.role === 'cell' && !memoryReservation ? `${reservation.credentialKind === 'invite' ? 'invite' : 'confirmation'}:${connId}` : null if (credentialActivityId) { @@ -954,6 +990,7 @@ export class HostSessionRegistry { client: socket, attachTimer, credentialActivityId, + phonePath: memoryReservation ? 'memory' : 'db', capacityReservation, // Attach starts where the activity stage ended, so the conn-open send is // charged to it and no wall-clock gap goes unattributed. @@ -982,6 +1019,74 @@ export class HostSessionRegistry { }) } + // Today's credential path: invites, and every resume while the phone path is db. `deviceMissed`: + // the memory path already found no device row, so only the invite lookup is left. + private async reserveCredentialFromDatabase( + socket: WebSocket, + hostId: string, + credential: string, + deviceMissed: boolean, + capacityReservation: PendingHostDataReservation | undefined, + abandonedByClient: (stage: RelayClientAcceptStage, cleanup?: () => void) => boolean, + markStage: (stage: RelayClientAcceptStage) => void + ): Promise { + if (this.config.role === 'cell') { + // Each lookup is its own pooled round trip; stop between them once the phone + // has left instead of running the rest of the chain for nobody. + let outerIdentity = deviceMissed ? null : await this.store.resolveResume(hostId, credential) + if (abandonedByClient('assignment')) return null + if (!outerIdentity) { + outerIdentity = await this.store.resolveInviteForMove(hostId, credential) + if (abandonedByClient('assignment')) return null + } + const assignment = outerIdentity + ? await this.assignments.resolve({ userId: outerIdentity.userId, relayHostId: hostId }) + : null + if (!assignment || assignment.cellId !== this.config.cellId) { + capacityReservation?.release() + this.observer.recordAuth(false) + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return null + } + if (abandonedByClient('assignment')) return null + } + markStage('assignment') + const reservation = await this.store.reserveCredential(hostId, credential) + if (!reservation) { + capacityReservation?.release() + this.observer.recordAuth(false) + this.rejectClient(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) + return null + } + return reservation + } + + // No seat for a memory resume: one assignment read tells "moved" from "offline". A row that + // names this cell may be a lost ledger write (stale up to the 10 min reconcile), so it is logged. + private async rejectMissedSeat( + socket: WebSocket, + reservation: CredentialReservation, + sessionKey: string + ): Promise { + let elsewhere: boolean + try { + const row = await this.assignments.resolve({ + userId: reservation.userId, + relayHostId: reservation.relayHostId + }) + elsewhere = row !== null && row.cellId !== this.config.cellId + if (row?.cellId === this.config.cellId) this.phoneAccepts.missRowNamesHere(reservation.relayHostId) + } catch (error) { + this.phoneAccepts.missReadError(isRelayDatabaseTransientError(error) ? 'database_transient' : 'error') + elsewhere = true + } + if (elsewhere) { + this.rejectClient(socket, RELAY_CLOSE_CODE.WRONG_CELL) + return + } + this.rejectClient(socket, RELAY_CLOSE_CODE.HOST_OFFLINE, this.hostCloseReasons.read(sessionKey)) + } + async acceptHostData( socket: WebSocket, connId: string, @@ -1041,7 +1146,8 @@ export class HostSessionRegistry { userId: pending.reservation.userId, relayHostId: pending.reservation.relayHostId } - const spliceActivityId = this.config.role === 'cell' ? `splice:${connId}` : null + const spliceActivityId = + this.config.role === 'cell' && pending.phonePath === 'db' ? `splice:${connId}` : null try { if (spliceActivityId) { await this.assignments.acquireActivity(identity, { @@ -1050,12 +1156,16 @@ export class HostSessionRegistry { cellId: this.config.cellId }) } - await this.store.recordConnectionBasis({ - ...pending.reservation, - basisConnId: connId, - owningControlGeneration: session.generation, - deadline: basisDeadline - }) + if (pending.phonePath === 'memory') { + this.openMemoryBasis(pending, connId, session.generation, basisDeadline) + } else { + await this.store.recordConnectionBasis({ + ...pending.reservation, + basisConnId: connId, + owningControlGeneration: session.generation, + deadline: basisDeadline + }) + } } catch { session.activeConnIds.delete(connId) await this.store.failReservation(pending.reservation) @@ -1078,7 +1188,7 @@ export class HostSessionRegistry { ) { session.activeConnIds.delete(connId) pending.capacityReservation?.release() - this.deactivateBasisBestEffort(connId) + this.closeBasis(pending, connId) this.failReservationBestEffort(pending.reservation) if (spliceActivityId) this.releaseActivityBestEffort(identity, spliceActivityId) if (pending.credentialActivityId) { @@ -1095,7 +1205,7 @@ export class HostSessionRegistry { onClose: () => { session.activeConnIds.delete(connId) session.activeSplices.delete(connId) - this.deactivateBasisBestEffort(connId) + this.closeBasis(pending, connId) if (spliceActivityId) this.releaseActivityBestEffort(identity, spliceActivityId) if (pending.credentialActivityId) { this.releaseActivityBestEffort(identity, pending.credentialActivityId) @@ -2377,11 +2487,12 @@ export class HostSessionRegistry { } if (type === 'device-resume-confirm') { const request = DeviceResumeConfirmSchema.parse(payload(raw, type)) - const result = await this.store.confirmResume({ - ...identity, - ...request, - owningControlGeneration: session.generation - }) + const confirm = { ...identity, ...request, owningControlGeneration: session.generation } + if (this.memoryConfirmations.owns(confirm)) { + send(session.socket, 'device-resume-confirmed', await this.memoryConfirmations.confirm(confirm)) + return + } + const result = await this.store.confirmResume(confirm) await this.assignments.releaseActivity(identity, `confirmation:${request.basisConnId}`) send(session.socket, 'device-resume-confirmed', result) return @@ -2448,6 +2559,34 @@ export class HostSessionRegistry { }) } + // A memory resume's basis is the cell's own record; every other basis is a Postgres row. + private openMemoryBasis( + pending: PendingConnection, + connId: string, + owningControlGeneration: number, + deadline: number + ): void { + const { reservation } = pending + if (reservation.acceptedCredentialVersion === undefined || reservation.acceptedAs === undefined) { + throw new Error('memory basis needs an accepted resume credential') + } + this.memoryConfirmations.open({ + basisConnId: connId, + userId: reservation.userId, + relayHostId: reservation.relayHostId, + relayDeviceId: reservation.relayDeviceId, + acceptedCredentialVersion: reservation.acceptedCredentialVersion, + acceptedAs: reservation.acceptedAs, + owningControlGeneration, + deadline + }) + } + + private closeBasis(pending: PendingConnection, connId: string): void { + if (pending.phonePath === 'memory') this.memoryConfirmations.close(connId) + else this.deactivateBasisBestEffort(connId) + } + private deactivateBasisBestEffort(connId: string): void { void this.store.deactivateBasis(connId).catch(() => { console.warn('[orca-relay] basis deactivation deferred to credential cleanup') diff --git a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts index b83cd57f92f9..ce84bf78e531 100644 --- a/cloud/apps/relay/src/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/idle-regional-rehome-store.test.ts @@ -217,6 +217,15 @@ describe('constrained idle regional assignment transaction', () => { expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) }) + // sourceRequestUnits reads a reserve cell's missing phone leases as zero units; selection must drop it. + it.each(['source', 'target'])('drops every candidate while the %s cell is recorded as reserve', async (cellId) => { + const { store, safety } = await setup() + await store.setCellAdmitMode(cellId, 'reserve') + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) + await store.setCellAdmitMode(cellId, 'db') + expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) + }) + it('leaves the candidate page offset untouched across a closed dispatch budget', async () => { const { store, database, safety } = await setup() const first = await store.selectIdleRegionalRehomeCandidates(safety) diff --git a/cloud/apps/relay/src/memory-resume-confirmations.ts b/cloud/apps/relay/src/memory-resume-confirmations.ts new file mode 100644 index 000000000000..c2de7a7be7a8 --- /dev/null +++ b/cloud/apps/relay/src/memory-resume-confirmations.ts @@ -0,0 +1,98 @@ +import type { DeviceResumeConfirmed } from '@orca-cloud/relay-contract' +import { RelayStoreError, type RelayCredentialStore, type RelayIdentity } from './credential-store.js' + +// Step 6: what a memory cell keeps instead of relay_connection_bases and relay_confirm_results. +// Both live and die with this process; a restart takes the splices with it. + +export type MemoryConnectionBasis = RelayIdentity & { + basisConnId: string + relayDeviceId: string + acceptedCredentialVersion: number + acceptedAs: 'current' | 'grace' + owningControlGeneration: number + deadline: number +} + +export type ResumeConfirmRequest = RelayIdentity & { + reqId: string + basisConnId: string + owningControlGeneration: number +} + +// A retried confirm of the same request answers from here, as relay_confirm_results does. +export const MEMORY_CONFIRM_RESULT_RETENTION_MS = 10 * 60 * 1000 + +type StoredResult = { basisConnId: string; result: DeviceResumeConfirmed; at: number } + +function resultKey(request: RelayIdentity & { reqId: string }): string { + return `${request.userId}\u0000${request.relayHostId}\u0000${request.reqId}` +} + +export class MemoryResumeConfirmations { + private readonly bases = new Map() + // Insertion order is age order, so expiry walks from the front. + private readonly results = new Map() + + constructor( + private readonly store: RelayCredentialStore, + private readonly now: () => number = Date.now, + private readonly onRenewalFailure?: (error: unknown) => void + ) {} + + open(basis: MemoryConnectionBasis): void { + this.bases.set(basis.basisConnId, basis) + } + + close(basisConnId: string): void { + this.bases.delete(basisConnId) + } + + // Routed by where the basis lives, not by the switch, so a flip between accept and confirm + // neither fails the confirm nor sends it to a database that holds no basis for it. + owns(request: ResumeConfirmRequest): boolean { + this.expireResults() + return this.bases.has(request.basisConnId) || this.results.has(resultKey(request)) + } + + async confirm(request: ResumeConfirmRequest): Promise { + this.expireResults() + const key = resultKey(request) + const prior = this.results.get(key) + if (prior) { + if (prior.basisConnId !== request.basisConnId) throw new RelayStoreError('confirmation_tuple_mismatch') + return prior.result + } + const basis = this.bases.get(request.basisConnId) + if ( + !basis || + basis.userId !== request.userId || + basis.relayHostId !== request.relayHostId || + basis.owningControlGeneration !== request.owningControlGeneration || + basis.deadline < this.now() + ) { + throw new RelayStoreError('confirmation_not_active') + } + const result = await this.store.confirmMemoryResume( + { + userId: basis.userId, + relayHostId: basis.relayHostId, + reqId: request.reqId, + basisConnId: basis.basisConnId, + relayDeviceId: basis.relayDeviceId, + acceptedCredentialVersion: basis.acceptedCredentialVersion, + acceptedAs: basis.acceptedAs + }, + { onRenewalFailure: this.onRenewalFailure } + ) + this.results.set(key, { basisConnId: basis.basisConnId, result, at: this.now() }) + return result + } + + private expireResults(): void { + const cutoff = this.now() - MEMORY_CONFIRM_RESULT_RETENTION_MS + for (const [key, stored] of this.results) { + if (stored.at > cutoff) break + this.results.delete(key) + } + } +} diff --git a/cloud/apps/relay/src/phone-accept-log.ts b/cloud/apps/relay/src/phone-accept-log.ts new file mode 100644 index 000000000000..f565ee7b4358 --- /dev/null +++ b/cloud/apps/relay/src/phone-accept-log.ts @@ -0,0 +1,61 @@ +import { AssignmentRejectionLogWindow } from './assignment-rejection-log-window.js' +import { relayHostLogDigest } from './relay-host-log-digest.js' + +// The memory phone path's two accept anomalies. A stuck database or a lost ledger write repeats +// per phone dial, so per event one line opens the window and one closing line carries the rest. +export const PHONE_ACCEPT_LOG_WINDOW_MS = 60_000 + +export type PhoneAcceptDatabaseCause = 'database_transient' | 'error' + +type PhoneAcceptAnomaly = + | { event: 'orca_relay_client_accept_db_error'; cause: PhoneAcceptDatabaseCause } + | { event: 'orca_relay_client_miss_row_names_here'; relayHostIdDigest: string } + | { event: 'orca_relay_client_miss_read_error'; cause: PhoneAcceptDatabaseCause } + | { event: 'orca_relay_resume_renewal_failed'; cause: PhoneAcceptDatabaseCause } + +export class PhoneAcceptLog { + private readonly window: AssignmentRejectionLogWindow + + constructor( + private readonly cellId: string, + private readonly warn: (line: string) => void = (line) => console.warn(line), + schedule?: (callback: () => void, delayMs: number) => () => void + ) { + this.window = new AssignmentRejectionLogWindow({ + windowMs: PHONE_ACCEPT_LOG_WINDOW_MS, + onWindowClosed: ({ suppressed, sample }) => this.write(sample, suppressed), + schedule + }) + } + + // The one device read failed; the phone was closed 4503 and never took the invite path. + databaseError(cause: PhoneAcceptDatabaseCause): void { + this.record({ event: 'orca_relay_client_accept_db_error', cause }) + } + + // No seat, yet the row names this cell: the 4404 may be stale (a lost reserve ledger write). + missRowNamesHere(relayHostId: string): void { + this.record({ + event: 'orca_relay_client_miss_row_names_here', + relayHostIdDigest: relayHostLogDigest(relayHostId) + }) + } + + // The no-seat assignment read failed; the phone was closed 4409 to re-resolve. + missReadError(cause: PhoneAcceptDatabaseCause): void { + this.record({ event: 'orca_relay_client_miss_read_error', cause }) + } + + // A due renewal write failed; the confirm still answered the stored expiry. + renewalFailed(cause: PhoneAcceptDatabaseCause): void { + this.record({ event: 'orca_relay_resume_renewal_failed', cause }) + } + + private record(anomaly: PhoneAcceptAnomaly): void { + if (this.window.admit(anomaly.event, anomaly)) this.write(anomaly, 0) + } + + private write(anomaly: PhoneAcceptAnomaly, suppressed: number): void { + this.warn(JSON.stringify({ ...anomaly, cellId: this.cellId, ...(suppressed > 0 ? { suppressed } : {}) })) + } +} diff --git a/cloud/apps/relay/src/relay-server.ts b/cloud/apps/relay/src/relay-server.ts index 7d98d8adb7b4..6c38c477e31d 100644 --- a/cloud/apps/relay/src/relay-server.ts +++ b/cloud/apps/relay/src/relay-server.ts @@ -372,6 +372,7 @@ export function createRelayServer( mode: effectiveAdmitMode, directorContact: () => reserveDeadMan?.contact(), reregisterInFlight: () => options.cellFlags?.().flags.reregisterInFlight, + phonePath: () => options.cellFlags?.().flags.phonePath ?? 'db', ticketEnforce: () => options.cellFlags?.().flags.ticketCheck === 'enforce', dryRunEnabled: () => options.cellFlags?.().flags.reserveDryRun === true, databaseShedding: () => databaseShedding(), @@ -444,6 +445,7 @@ export function createRelayServer( effectiveAdmitMode() === 'reserve' || sessions.reregistrationPending() > 0 ? 'reserve' : 'db', + cellPhonePathEffective: () => sessions.phonePathEffective(), cellDemote: (request: DemoteRequest) => sessions.demote(request), cellReserveCounts: () => sessions.reserveCounts(), cellPlacementCeiling: placementCeiling @@ -872,6 +874,7 @@ export function createRelayServer( controls.close() clients.close() dataSockets.close() + store.resumeAudit.stop() }) const runtimeCounts = () => { const ledgerCounts = connectionLedger?.counts() diff --git a/cloud/apps/relay/src/resume-confirmed-audit-buffer.test.ts b/cloud/apps/relay/src/resume-confirmed-audit-buffer.test.ts new file mode 100644 index 000000000000..a213074f03c0 --- /dev/null +++ b/cloud/apps/relay/src/resume-confirmed-audit-buffer.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from './database.js' +import { + RESUME_AUDIT_BUFFER_MAX_ROWS, + RESUME_AUDIT_FLUSH_MS, + RESUME_AUDIT_FLUSH_ROWS, + RESUME_AUDIT_MAX_AGE_MS, + ResumeConfirmedAuditBuffer +} from './resume-confirmed-audit-buffer.js' +import { StatementRecordingDatabase } from './test-fixtures/statement-recording-database.js' + +const event = (reqId: string) => ({ + userId: 'user-1', + relayHostId: 'host000000000001', + relayDeviceId: 'device-1', + detail: { reqId, basisConnId: `basis-${reqId}`, renewed: true } +}) + +describe('the batched resume-confirmed audit', () => { + let inner: RelayDatabase | undefined + + afterEach(async () => { + await inner?.close() + inner = undefined + }) + + async function setup(now: () => number = Date.now) { + inner = await openInMemoryRelayDatabase() + const database = new StatementRecordingDatabase(inner) + const lines: Array> = [] + const buffer = new ResumeConfirmedAuditBuffer(database, now, (line) => lines.push(JSON.parse(line))) + const rows = async (): Promise => + await inner!.query(`SELECT type, relay_device_id, detail_json FROM relay_audit_events ORDER BY at, id`) + return { database, buffer, lines, rows } + } + + it('writes buffered rows as one multi-row insert', async () => { + const { database, buffer, rows } = await setup() + for (const reqId of ['a', 'b', 'c']) buffer.add(event(reqId)) + expect(await rows()).toEqual([]) + await buffer.flush() + expect(database.statements).toEqual([ + expect.stringMatching(/^INSERT INTO relay_audit_events .* ON CONFLICT \(id\) DO NOTHING$/) + ]) + expect((await rows()).map((row) => JSON.parse(String(row.detail_json)).reqId).sort()).toEqual(['a', 'b', 'c']) + expect((await rows())[0]).toMatchObject({ type: 'resume-confirmed', relay_device_id: 'device-1' }) + buffer.stop() + }) + + it('flushes at once when a batch fills, without waiting for the timer', async () => { + const { buffer, rows } = await setup() + for (let index = 0; index < RESUME_AUDIT_FLUSH_ROWS; index += 1) buffer.add(event(`r${index}`)) + await buffer.flush() + expect(await rows()).toHaveLength(RESUME_AUDIT_FLUSH_ROWS) + buffer.stop() + }) + + it('retries a flush whose commit outcome was lost without writing any row twice', async () => { + const { database, buffer, rows } = await setup() + buffer.add(event('a')) + buffer.add(event('b')) + // The insert commits, then the reply is lost. + const query = database.query.bind(database) + let lost = true + database.query = async (sql, params) => { + const result = await query(sql, params) + if (lost) { + lost = false + throw new Error('Connection terminated unexpectedly') + } + return result + } + await buffer.flush() + expect(buffer.pending()).toBe(2) + await buffer.flush() + expect(buffer.pending()).toBe(0) + expect(await rows()).toHaveLength(2) + buffer.stop() + }) + + it('caps the buffer through an outage and drops rows too old to retry, counting both', async () => { + let now = 1_000_000 + const { database, buffer, lines, rows } = await setup(() => now) + database.failing = () => true + for (let index = 0; index <= RESUME_AUDIT_BUFFER_MAX_ROWS; index += 1) buffer.add(event(`r${index}`)) + // The batch the 500th row sent fails and comes back: rows in flight count toward the cap. + await buffer.flush() + expect(buffer.pending()).toBe(RESUME_AUDIT_BUFFER_MAX_ROWS) + expect(lines).toEqual([{ event: 'orca_relay_resume_audit_dropped', overflow: 1, aged: 0 }]) + now += RESUME_AUDIT_MAX_AGE_MS + database.failing = null + await buffer.flush() + expect(buffer.drops()).toEqual({ overflow: 1, aged: RESUME_AUDIT_BUFFER_MAX_ROWS }) + expect(lines.at(-1)).toEqual({ + event: 'orca_relay_resume_audit_dropped', + overflow: 1, + aged: RESUME_AUDIT_BUFFER_MAX_ROWS + }) + expect(await rows()).toEqual([]) + // Quiet once nothing more is lost. + await buffer.flush() + expect(lines).toHaveLength(2) + buffer.stop() + }) + + it('sends one early batch on crossing the batch size, and leaves retries to the timer', async () => { + const { database, buffer } = await setup() + database.failing = () => true + for (let index = 0; index < RESUME_AUDIT_FLUSH_ROWS + 50; index += 1) buffer.add(event(`r${index}`)) + await buffer.flush() + // The 50 adds past the batch size sent nothing more: no insert per confirm into a failing database. + expect(database.statements).toHaveLength(1) + buffer.stop() + }) + + it('writes nothing more once stopped', async () => { + vi.useFakeTimers() + try { + const { database, buffer } = await setup() + buffer.stop() + buffer.add(event('late')) + await vi.advanceTimersByTimeAsync(RESUME_AUDIT_FLUSH_MS * 3) + expect(database.statements).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) + diff --git a/cloud/apps/relay/src/resume-confirmed-audit-buffer.ts b/cloud/apps/relay/src/resume-confirmed-audit-buffer.ts new file mode 100644 index 000000000000..f23239126fa2 --- /dev/null +++ b/cloud/apps/relay/src/resume-confirmed-audit-buffer.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto' +import type { RelayDatabase } from './database.js' + +// Step 6 (decision 4): a memory cell's resume-confirmed audit rows leave in batches, off the +// confirm's round trips. A crash or shutdown loses at most one flush interval of rows: accepted, +// since a drain at exit would race the database pool closing. +export const RESUME_AUDIT_FLUSH_MS = 5_000 +export const RESUME_AUDIT_FLUSH_ROWS = 500 +export const RESUME_AUDIT_BUFFER_MAX_ROWS = 5_000 +// An erasure deletes an account's audit rows by hand; a row this old is dropped rather than +// retried, so a database outage cannot insert it after the account is gone. +export const RESUME_AUDIT_MAX_AGE_MS = 10 * 60 * 1000 + +export type ResumeConfirmedAudit = { + userId: string + relayHostId: string + relayDeviceId: string + detail: { reqId: string; basisConnId: string; renewed: boolean } +} + +// The id is fixed when the event happens, so a retried flush after an unknown commit outcome +// cannot write a row twice (ON CONFLICT (id) DO NOTHING). +type BufferedRow = ResumeConfirmedAudit & { id: string; at: number } + +export type ResumeAuditDrops = { overflow: number; aged: number } + +export class ResumeConfirmedAuditBuffer { + private rows: BufferedRow[] = [] + private flushing: Promise | null = null + private timer: ReturnType | null = null + private readonly dropped: ResumeAuditDrops = { overflow: 0, aged: 0 } + private reportedDrops = 0 + private stopped = false + + constructor( + private readonly database: RelayDatabase, + private readonly now: () => number = Date.now, + private readonly warn: (line: string) => void = (line) => console.warn(line) + ) {} + + add(event: ResumeConfirmedAudit): void { + this.rows.push({ ...event, id: randomUUID(), at: this.now() }) + this.trimToCap() + if (!this.stopped && !this.timer) { + this.timer = setInterval(() => void this.flush(), RESUME_AUDIT_FLUSH_MS) + this.timer.unref?.() + } + // Only on crossing the batch size: retries of a failing database wait for the timer. + if (this.rows.length === RESUME_AUDIT_FLUSH_ROWS) void this.flush() + } + + // Rows lost since boot: the watcher pages on any. + drops(): ResumeAuditDrops { + return { ...this.dropped } + } + + pending(): number { + return this.rows.length + } + + // One flush in flight at a time; a failed batch goes back to the front with its ids. + async flush(): Promise { + if (this.flushing) return await this.flushing + this.flushing = this.flushOnce().finally(() => { + this.flushing = null + }) + return await this.flushing + } + + stop(): void { + this.stopped = true + if (this.timer) clearInterval(this.timer) + this.timer = null + } + + // The oldest rows go first; rows back from a failed batch count toward the cap. + private trimToCap(): void { + const excess = this.rows.length - RESUME_AUDIT_BUFFER_MAX_ROWS + if (excess <= 0) return + this.rows.splice(0, excess) + this.dropped.overflow += excess + } + + private async flushOnce(): Promise { + const aged = this.now() - RESUME_AUDIT_MAX_AGE_MS + const before = this.rows.length + this.rows = this.rows.filter((row) => row.at > aged) + this.dropped.aged += before - this.rows.length + const batch = this.rows.splice(0, RESUME_AUDIT_FLUSH_ROWS) + if (batch.length > 0) { + try { + await this.database.query( + `INSERT INTO relay_audit_events + (id, at, type, user_id, relay_host_id, relay_device_id, detail_json) + VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')} + ON CONFLICT (id) DO NOTHING`, + batch.flatMap((row) => [ + row.id, row.at, 'resume-confirmed', row.userId, row.relayHostId, row.relayDeviceId, + JSON.stringify(row.detail) + ]) + ) + } catch { + this.rows.unshift(...batch) + this.trimToCap() + } + } + // At most one line per flush, carrying the totals since boot. + const total = this.dropped.overflow + this.dropped.aged + if (total > this.reportedDrops) { + this.reportedDrops = total + this.warn(JSON.stringify({ event: 'orca_relay_resume_audit_dropped', ...this.drops() })) + } + } +} diff --git a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts index 0242f499a631..265e5bfaa615 100644 --- a/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts +++ b/cloud/apps/relay/src/test-fixtures/reserve-mode-cell.ts @@ -28,7 +28,7 @@ async function unusedPort(): Promise { return address.port } -function nextMessage(socket: WebSocket): Promise> { +export function nextMessage(socket: WebSocket): Promise> { return new Promise((resolve, reject) => { socket.once('message', (data: RawData) => { try { @@ -42,6 +42,19 @@ function nextMessage(socket: WebSocket): Promise> { }) } +// The next frame of this type, skipping pings and anything else the socket carries first. +export function nextFrame(socket: WebSocket, type: string): Promise> { + return new Promise((resolve) => { + const onMessage = (data: RawData) => { + const frame: unknown = JSON.parse(data.toString()) + if (!frame || typeof frame !== 'object' || !('type' in frame) || frame.type !== type) return + socket.off('message', onMessage) + resolve(Object.fromEntries(Object.entries(frame))) + } + socket.on('message', onMessage) + }) +} + export async function startReserveModeCell(input: { database: RelayDatabase dataDir: string @@ -199,6 +212,64 @@ export async function startReserveModeCell(input: { return { identity, ...reply } } + // A paired phone's device row, as an install would have written it. + const installDevice = async ( + relayHostId: string, + resumeToken: string, + options: { expiresAt?: number; revokedAt?: number; relayDeviceId?: string } = {} + ) => + await input.database.query( + `INSERT INTO relay_devices + (user_id, relay_host_id, relay_device_id, current_hash, current_version, + current_expires_at, revoked_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'user-1', relayHostId, options.relayDeviceId ?? 'device-1', + createHash('sha256').update(resumeToken).digest('base64url'), 1, + options.expiresAt ?? Date.now() + 20 * 24 * 60 * 60 * 1000, options.revokedAt ?? null, Date.now() + ] + ) + + // A phone's dial: its first relay-hello frame and its close. + let phoneSource = 0 + const dialPhone = async (relayHostId: string, credential: string) => { + phoneSource += 1 + const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/connect/${relayHostId}`, { + headers: { 'x-forwarded-for': `spoofed, 192.0.2.${phoneSource}, 35.191.0.1` }, + perMessageDeflate: false + }) + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()) + socket.once('error', reject) + }) + input.cleanup.push(() => socket.terminate()) + const closed = new Promise<{ code: number; reason: string }>((resolve) => + socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() })) + ) + const hello = nextMessage(socket) + socket.send(JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential })) + return { socket, hello, closed } + } + + // The desktop's data leg for a conn-open its control received. + const attachData = async (connOpen: Record, generation: unknown) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/host/data/${String(connOpen.connId)}`, { + perMessageDeflate: false + }) + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()) + socket.once('error', reject) + }) + input.cleanup.push(() => socket.terminate()) + socket.send(JSON.stringify({ type: 'host-data-auth', v: 1, connTicket: connOpen.connTicket, generation })) + return socket + } + + const activityLeases = async () => + await input.database.query( + `SELECT activity_id, activity_kind FROM relay_assignment_activity_leases ORDER BY activity_id` + ) + const controlLeases = async () => await input.database.query( `SELECT relay_host_id, cell_id, activity_id FROM relay_assignment_activity_leases @@ -221,6 +292,10 @@ export async function startReserveModeCell(input: { insertRow, bookedHost, controlLeases, + activityLeases, + installDevice, + dialPhone, + attachData, admitEffectiveRow, setAdmitMode: (admitMode: 'db' | 'reserve', flags: Partial = {}) => { applied = { generation: applied.generation + 1, flags: { ...CELL_FLAG_DEFAULTS, ...flags, admitMode } } diff --git a/cloud/apps/relay/src/test-fixtures/statement-recording-database.ts b/cloud/apps/relay/src/test-fixtures/statement-recording-database.ts new file mode 100644 index 000000000000..d195ed6c552c --- /dev/null +++ b/cloud/apps/relay/src/test-fixtures/statement-recording-database.ts @@ -0,0 +1,59 @@ +import type { + RelayDatabase, + RelayLockOptions, + RelayTransactionOptions, + SqlRow +} from '../database.js' + +// Every statement a relay path issues, in order, so its round trips are counted rather than +// described. BEGIN and COMMIT are recorded too: each is a trip to a remote database. +export class StatementRecordingDatabase implements RelayDatabase { + readonly dialect: RelayDatabase['dialect'] + // A statement this matches fails as a dropped connection would, before reaching the database. + failing: ((sql: string) => boolean) | null = null + + constructor( + private readonly inner: RelayDatabase, + readonly statements: string[] = [] + ) { + this.dialect = inner.dialect + } + + async query(sql: string, params: unknown[] = []): Promise { + this.statements.push(normalized(sql)) + if (this.failing?.(normalized(sql))) throw new Error('Connection terminated unexpectedly') + return await this.inner.query(sql, params) + } + + async queryLocked(sql: string, params: unknown[] = [], options: RelayLockOptions = {}): Promise { + this.statements.push(`${normalized(sql)} [locked]`) + return await this.inner.queryLocked(sql, params, options) + } + + async transaction( + operation: (transaction: RelayDatabase) => Promise, + options: RelayTransactionOptions = {} + ): Promise { + this.statements.push('BEGIN') + try { + return await this.inner.transaction( + async (transaction) => { + const recording = new StatementRecordingDatabase(transaction, this.statements) + recording.failing = this.failing + return await operation(recording) + }, + options + ) + } finally { + this.statements.push('COMMIT') + } + } + + async close(): Promise { + await this.inner.close() + } +} + +function normalized(sql: string): string { + return sql.replace(/\s+/g, ' ').trim() +} diff --git a/cloud/dev/scripts/check-relay-same-cap-headroom.mjs b/cloud/dev/scripts/check-relay-same-cap-headroom.mjs index 95fad7b8543b..6cc0bebf64ea 100644 --- a/cloud/dev/scripts/check-relay-same-cap-headroom.mjs +++ b/cloud/dev/scripts/check-relay-same-cap-headroom.mjs @@ -1,5 +1,6 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' +import { isReserveCellRefusal, reserveCellRollRefusal } from './relay-reserve-cell-refusal.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; @@ -57,6 +58,8 @@ async function adminJson(fetchImpl, url, token, body, wait) { { wait } ) const json = await response.json().catch(() => ({})) + // null: the director refused the read because the cell is reserve. + if (isReserveCellRefusal(response.status, json)) return null if (!response.ok) throw new Error(`${new URL(url).pathname} returned ${response.status}`) return json } @@ -89,15 +92,18 @@ export async function checkSameCapHeadroom(config, overrides = {}) { 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( + const answer = 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 + if (answer === null) return null + if (answer.status?.cellId !== cellId) { + throw new Error('director status does not match the cell') + } + return answer.status } const runtime = await adminJson( fetchImpl, @@ -106,16 +112,23 @@ export async function checkSameCapHeadroom(config, overrides = {}) { { v: 1 }, wait ) - if (runtime.role !== 'cell' || runtime.cellId !== config.cellId) { + 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 targetStatus = await cellStatus(config.cellId) + if (targetStatus === null) throw reserveCellRollRefusal(config.cellId) + const targetRegion = targetStatus.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) }) + // A reserve cell's free seats are not in the database: counted as none, never guessed. + cells.push( + status === null + ? { cellId, region: null, freeSlots: 0, reserve: true } + : { cellId, region: status.region ?? null, freeSlots: freeSlots(status) } + ) } const total = (filter) => cells.filter(filter).reduce((sum, cell) => sum + cell.freeSlots, 0) diff --git a/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs b/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs index 66b5eda3bacb..de6304c7037a 100644 --- a/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs +++ b/cloud/dev/scripts/check-relay-same-cap-headroom.test.mjs @@ -134,6 +134,46 @@ test('no general cells means no headroom', async () => { assert.equal(result.sufficient, false) }) +const reserveRefusal = () => Response.json({ error: 'cell_in_reserve_mode' }, { status: 404 }) + +test('a reserve cell that is not being rolled offers no slots and is named', async () => { + const base = harness({ controls: 160 }) + const result = await run(async (url, options) => + JSON.parse(options.body).cellId === 'production-gce-c29' + ? reserveRefusal() + : await base(url, options)) + assert.equal(result.freeSlots, 200) + assert.equal(result.sufficient, true) + assert.deepEqual(result.cells.find((entry) => entry.cellId === 'production-gce-c29'), { + cellId: 'production-gce-c29', + region: null, + freeSlots: 0, + reserve: true + }) +}) + +test('refuses to roll a reserve cell, naming the flip back to db', async () => { + const base = harness() + await assert.rejects( + run(async (url, options) => + JSON.parse(options.body ?? '{}').cellId === config.cellId + ? reserveRefusal() + : await base(url, options)), + /production-gce-c28 is in reserve mode .*flip it to admitMode=db/ + ) +}) + +test('any other 404 from cell-status still fails', async () => { + const base = harness() + await assert.rejects( + run(async (url, options) => + JSON.parse(options.body).cellId === 'production-gce-c29' + ? Response.json({ error: 'cell_not_found' }, { status: 404 }) + : await base(url, options)), + /cell-status returned 404/ + ) +}) + test('rejects a runtime or director answer for another cell', async () => { const base = harness() await assert.rejects( diff --git a/cloud/dev/scripts/deploy-relay-gce-candidate.mjs b/cloud/dev/scripts/deploy-relay-gce-candidate.mjs index 6a0928041c99..0c324dda5ea3 100644 --- a/cloud/dev/scripts/deploy-relay-gce-candidate.mjs +++ b/cloud/dev/scripts/deploy-relay-gce-candidate.mjs @@ -6,6 +6,7 @@ import { selectorCellState, transitionAdmissionSelector } from './relay-admission-selector.mjs' +import { isReserveCellRefusal, reserveCellRollRefusal } from './relay-reserve-cell-refusal.mjs' const DEFAULT_POLL_INTERVAL_MS = 5_000 const DEFAULT_TIMEOUT_MS = 14 * 60 * 1_000 @@ -304,8 +305,9 @@ export function defaultIdentityToken(audience) { return result.stdout.trim() } -async function responseJson(response, label) { +async function responseJson(response, label, cellId) { const body = await response.json().catch(() => ({ error: `http_${response.status}` })) + if (isReserveCellRefusal(response.status, body)) throw reserveCellRollRefusal(cellId) if (!response.ok) throw new Error(`${label} failed: ${body.error ?? response.status}`) return body } @@ -349,7 +351,10 @@ export function createAdminPost(config, deps, token) { await deps.wait(deps.random() * ADMIN_RETRY_BASE_MS * 2 ** (attempt - 1)) continue } - return await responseJson(response, path) + // The director names no cell, so a two-cell request names both. + const cells = + body?.cellId ?? [body?.sourceCellId, body?.targetCellId].filter(Boolean).join(' or ') + return await responseJson(response, path, cells || null) } throw new Error(`${path} retry attempts exhausted`) } diff --git a/cloud/dev/scripts/deploy-relay-gce-candidate.test.mjs b/cloud/dev/scripts/deploy-relay-gce-candidate.test.mjs index 3eccd1cf1912..3dc33d659532 100644 --- a/cloud/dev/scripts/deploy-relay-gce-candidate.test.mjs +++ b/cloud/dev/scripts/deploy-relay-gce-candidate.test.mjs @@ -62,6 +62,39 @@ test('retries evacuation-status HTTP 500 without widening other admin retries', assert.equal(calls, 1) }) +test('a reserve-cell refusal names the flip back to db; other 404s keep their error', async () => { + const post = (body) => createAdminPost({}, { + fetch: async () => Response.json(body, { status: 404 }), + emit: () => {}, + wait: async () => {}, + random: () => 0 + }, 'token') + await assert.rejects( + post({ error: 'cell_in_reserve_mode' })( + 'https://relay.example', + '/v1/admin/cell-status', + { v: 1, cellId: 'production-gce-c7' } + ), + /production-gce-c7 is in reserve mode .*flip it to admitMode=db/ + ) + await assert.rejects( + post({ error: 'cell_not_found' })( + 'https://relay.example', + '/v1/admin/cell-status', + { v: 1, cellId: 'production-gce-c7' } + ), + /cell-status failed: cell_not_found/ + ) + await assert.rejects( + post({ error: 'cell_in_reserve_mode' })( + 'https://relay.example', + '/v1/admin/evacuation-status', + { v: 1, sourceCellId: 'production-gce-c7', targetCellId: 'production-gce-c8' } + ), + /production-gce-c7 or production-gce-c8 is in reserve mode/ + ) +}) + test('verifies a 1,000-cap cell against both runtime and director telemetry', () => { const expected = deployment( { diff --git a/cloud/dev/scripts/operate-relay-cell-flags.mjs b/cloud/dev/scripts/operate-relay-cell-flags.mjs index 6eebd7cd2ed7..5456cc59fca6 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.mjs @@ -43,7 +43,9 @@ export const CELL_FLAG_SPECS = { // Absent keeps the cell's boot value. readTimeoutMarginMs: { parse: numberIn(1_000, 60_000, true) }, // Flip-back leases in flight; absent is a third of the cell's pool. - reregisterInFlight: { parse: numberIn(1, 16, true) } + reregisterInFlight: { parse: numberIn(1, 16, true) }, + // Step 6: acts only while the cell is reserve. + phonePath: { parse: enumOf('db', 'memory'), default: 'db' } } export const RESERVE_CONFIRMATION = 'RESERVE' @@ -136,6 +138,24 @@ export function assertReserveKeepsLocalReadiness(flags, changes) { ) } +// The cell ignores phonePath=memory outside reserve, so writing it there only arms a later +// admitMode=reserve flip to turn phones over unnamed. Rolling back with admitMode=db stays allowed. +export function assertPhonePathNeedsReserve(flags, changes) { + if ((flags.phonePath ?? 'db') !== 'memory') return + if ((flags.admitMode ?? 'db') === 'reserve') { + if (changes.admitMode === 'reserve' && !('phonePath' in changes)) { + throw new Error( + 'the object still says phonePath=memory: name phonePath in the same write ' + + '(e.g. --set admitMode=reserve,phonePath=default), so a reserve flip never turns phones over unnamed' + ) + } + return + } + if ('phonePath' in changes) { + throw new Error("phonePath=memory requires admitMode=reserve on the cell, already written or in the same write") + } +} + // `key=value,key=value`; `key=default` removes the key so the cell uses its default. export function parseFlagChanges(text) { const changes = {} @@ -152,7 +172,8 @@ export function parseFlagChanges(text) { } const value = spec.parse(raw) if (value === undefined) throw new Error(`switch ${key} cannot be ${JSON.stringify(raw)}`) - changes[key] = value + // phonePath=db is the default, stored as no key: an image without the switch refuses the key. + changes[key] = key === 'phonePath' && value === 'db' ? null : value } if (Object.keys(changes).length === 0) throw new Error('set names no switch to change') return changes @@ -418,7 +439,8 @@ async function breakGlassToDatabase(request, dependencies, { fetchImpl, log, sle if (current.generation !== request.expectedGeneration) { throw generationConflict(current.generation, request.expectedGeneration) } - const object = desiredObject(request, current.object) + // phonePath goes too: a dead cell comes back with phones on the database path as well. + const object = desiredObject({ ...request, changes: { ...request.changes, phonePath: null } }, current.object) const generation = await writeObject(fetchImpl, request, object, accessToken, audit) await directorAdmitMode(fetchImpl, request, idToken, sleep, 'db') const result = { event: 'orca_relay_cell_admit_mode_break_glass', cellId: request.cellId, generation, audit } @@ -453,6 +475,7 @@ export async function operateCellFlags(request, dependencies) { ) } assertReserveKeepsLocalReadiness(object.flags, request.changes) + assertPhonePathNeedsReserve(object.flags, request.changes) const plan = { event: 'orca_relay_cell_flags_plan', mode: request.mode, diff --git a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs index 1b72cb6aebf7..5d30e7e9483f 100644 --- a/cloud/dev/scripts/operate-relay-cell-flags.test.mjs +++ b/cloud/dev/scripts/operate-relay-cell-flags.test.mjs @@ -36,7 +36,8 @@ const SUPPORTED = { reserveDryRun: { type: 'boolean' }, rejectionFence: { type: 'boolean' }, readTimeoutMarginMs: { type: 'number', min: 1_000, max: 60_000, integer: true }, - reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true } + reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true }, + phonePath: { type: 'enum', values: ['db', 'memory'] } } function fakeGoogleAndCell({ @@ -499,6 +500,54 @@ test('a reserve cell refuses readinessLocal=false, but takes it together with th assert.deepEqual(fake.state.writes[0].object.flags, { readinessLocal: false, admitMode: 'db' }) }) +test('phonePath=memory needs reserve, and a reserve flip never turns phones over unnamed', async () => { + const onDatabase = { generation: '7', object: { v: 1, cellId: CELL, flags: { readinessLocal: true } } } + const refused = fakeGoogleAndCell({ stored: onDatabase }) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'phonePath=memory', 'expected-generation': '7' })), refused).result, + /phonePath=memory requires admitMode=reserve/ + ) + assert.deepEqual(refused.state.writes, []) + + const together = fakeGoogleAndCell({ stored: onDatabase }) + const both = parseCellFlagsRequest( + values({ set: 'admitMode=reserve,phonePath=memory', 'expected-generation': '7', ...RESERVE }) + ) + assert.equal((await run(both, together).result).written, true) + assert.deepEqual(together.state.writes[0].object.flags, { + readinessLocal: true, + admitMode: 'reserve', + phonePath: 'memory' + }) + + const reserve = { readinessLocal: true, admitMode: 'reserve' } + const onReserve = fakeGoogleAndCell({ stored: { ...onDatabase, object: { ...onDatabase.object, flags: reserve } } }) + const alone = parseCellFlagsRequest(values({ set: 'phonePath=memory', 'expected-generation': '7' })) + assert.equal((await run(alone, onReserve).result).written, true) + + // Rolling back with admitMode=db leaves phonePath=memory in the object, which the cell ignores. + const memory = { ...reserve, phonePath: 'memory' } + const rollback = fakeGoogleAndCell({ stored: { ...onDatabase, object: { ...onDatabase.object, flags: memory } } }) + rollback.state.directorAdmitMode = 'reserve' + const toDatabase = parseCellFlagsRequest(values({ ...DIRECTOR, set: 'admitMode=db', 'expected-generation': '7' })) + assert.equal((await run(toDatabase, rollback).result).written, true) + assert.deepEqual(rollback.state.writes[0].object.flags, { ...memory, admitMode: 'db' }) + + // phonePath=db removes the key, so an image without the switch still takes later writes. + const off = fakeGoogleAndCell({ stored: { ...onDatabase, object: { ...onDatabase.object, flags: memory } } }) + assert.equal((await run(parseCellFlagsRequest(values({ set: 'phonePath=db', 'expected-generation': '7' })), off).result).written, true) + assert.deepEqual(off.state.writes[0].object.flags, reserve) + + const lingering = { readinessLocal: true, admitMode: 'db', phonePath: 'memory' } + const unnamed = fakeGoogleAndCell({ stored: { ...onDatabase, object: { ...onDatabase.object, flags: lingering } } }) + await assert.rejects( + run(parseCellFlagsRequest(values({ set: 'admitMode=reserve', 'expected-generation': '7', ...RESERVE })), unnamed) + .result, + /still says phonePath=memory/ + ) + assert.deepEqual(unnamed.state.writes, []) +}) + test('a migration opened during the cache wait puts Postgres back to db', async () => { const fake = fakeGoogleAndCell({ refuseReservePost: { post: 2, error: 'cell_has_open_migration' } }) const request = parseCellFlagsRequest(values({ ...RESERVE, set: 'readinessLocal=true,admitMode=reserve' })) @@ -537,7 +586,7 @@ test('a flip back that outlasts its wait keeps Postgres on reserve and says how assert.equal(lines.find((line) => line.event === 'orca_relay_cell_reregistration_wait')?.waitMs, 1_666_667) }) -test('break glass records db for a cell that cannot answer, typed for that cell only', async () => { +test('break glass records db (and drops phonePath) for a cell that cannot answer, typed for that cell only', async () => { const glass = { ...RESERVE, set: 'admitMode=default', 'expected-generation': '7' } assert.throws(() => parseCellFlagsRequest(values({ ...glass, 'break-glass': 'BREAK GLASS production-gce-c27' })), /break glass requires/) assert.throws( @@ -545,7 +594,7 @@ test('break glass records db for a cell that cannot answer, typed for that cell /on its own/ ) const fake = fakeGoogleAndCell({ - stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve', readinessLocal: true } } } + stored: { generation: '7', object: { v: 1, cellId: CELL, flags: { admitMode: 'reserve', readinessLocal: true, phonePath: 'memory' } } } }) fake.state.directorAdmitMode = 'reserve' const inner = fake.fetchImpl diff --git a/cloud/dev/scripts/relay-evidence-code-provenance.mjs b/cloud/dev/scripts/relay-evidence-code-provenance.mjs index 3e6ccb913103..dba11559e6fa 100644 --- a/cloud/dev/scripts/relay-evidence-code-provenance.mjs +++ b/cloud/dev/scripts/relay-evidence-code-provenance.mjs @@ -33,6 +33,8 @@ export const TRUSTED_EVIDENCE_CODE_PATHS = [ relayTreePath('dev/scripts/probe-relay-rehome-trust.mjs'), relayTreePath('dev/scripts/validate-relay-capacity-plan.mjs'), relayTreePath('dev/scripts/verify-relay-capacity-transition.mjs'), + relayTreePath('dev/scripts/relay-admin-transient-retry.mjs'), + relayTreePath('dev/scripts/relay-reserve-cell-refusal.mjs'), // The exit metric filter the same-cap pre-drain sample's crash rule reads. relayTreePath('infra/terraform/relay-observability.tf'), // The monitor, the live preflight recheck, and the same-cap pre-drain sample, plus anything that diff --git a/cloud/dev/scripts/relay-phone-watch-recorded-quiet-minutes.json b/cloud/dev/scripts/relay-phone-watch-recorded-quiet-minutes.json new file mode 100644 index 000000000000..0593c68ab634 --- /dev/null +++ b/cloud/dev/scripts/relay-phone-watch-recorded-quiet-minutes.json @@ -0,0 +1 @@ +{"source":"production cell runtime metrics, read-only, 2026-10-18T06:00Z to 2026-10-19T08:00Z; per-minute sums of authFailuresDelta and clientAcceptCompletedDelta by the minute each 30 s line ends in","cells":{"production-gce-c7":{"authFailuresPerMinute":[0,1,0,1,2,0,0,0,0,0,7,0,0,0,0,0,0,0,0,0,0,1,1,0,2,1,1,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,2,1,0,1,1,0,2,2,1,0,1,0,0,0,0,0,0,0,1,0,0,0,1,1,0,1,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,1,0,0,2,1,1,0,3,2,2,1,3,5,1,0,1,2,1,2,1,2,0,5,4,1,1,4,0,1,0,1,3,2,4,2,1,3,2,5,1,3,1,2,1,1,2,2,1,1,3,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,4,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1,0,0,0,0,2,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,2,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,2,0,0,0,0,1,0,0,0,1,2,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,1,0,0,0,0,2,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,0,0,0,2,1,0,2,0,0,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,1,0,1,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,2,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,1,1,0,1,0,2,2,2,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,2,3,0,0,0,0,0,1,0,0,1,0,0,1,0,0,1,0,0,2,0,1,0,1,0,0,0,0,0,1,0,3,0,1,1,1,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,4,1,1,1,3,1,1,1,0,1,1,0,1,2,2,2,0,0,1,6,0,1,1,1,1,2,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,1,0,4,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0],"completedAcceptsPerMinute":[2,0,1,2,0,2,1,5,1,1,2,3,1,4,0,0,2,4,1,0,0,0,1,1,2,2,2,1,2,2,1,1,0,0,2,0,0,2,1,1,1,2,0,0,1,2,1,0,2,1,0,1,1,0,1,0,1,0,1,0,0,0,1,1,4,0,1,0,1,0,2,0,0,0,0,0,2,2,0,1,1,1,1,1,0,0,0,1,2,1,0,0,0,1,0,1,3,0,0,3,0,0,1,1,0,2,1,1,2,1,2,0,1,1,2,1,3,2,1,1,0,1,0,1,2,4,2,3,4,0,1,1,3,2,0,1,2,1,2,2,2,3,4,2,3,1,3,1,0,2,2,0,0,3,1,1,2,1,3,2,2,2,2,3,1,3,1,3,3,1,1,2,0,0,3,0,4,2,3,3,1,2,2,4,3,3,2,3,4,4,0,4,4,3,2,1,0,2,1,2,3,2,3,3,1,4,2,2,2,3,3,1,4,2,1,4,2,3,4,2,0,1,4,2,1,0,0,2,0,1,4,2,1,4,3,3,3,0,2,3,3,3,2,3,5,2,5,3,1,3,0,5,2,1,4,1,6,1,3,7,4,4,1,4,3,1,1,2,2,3,3,4,0,3,0,2,2,3,2,1,2,3,0,2,0,2,2,1,6,2,4,1,2,4,1,1,3,1,1,3,3,5,3,1,2,2,2,3,2,2,3,2,5,6,2,3,3,2,5,3,2,1,5,3,2,3,6,8,6,1,2,2,1,5,6,8,1,2,7,6,4,3,5,4,6,2,5,5,2,3,2,1,5,1,2,3,6,4,1,4,1,1,5,2,1,3,2,4,3,2,1,1,4,4,2,3,4,2,3,3,1,2,0,4,1,4,3,2,0,4,2,2,1,2,2,1,1,6,2,4,3,1,0,1,1,4,0,1,1,4,1,4,4,1,1,3,2,2,3,3,1,0,1,2,0,2,2,1,2,1,2,2,1,7,4,1,3,0,0,2,2,1,1,1,0,3,7,2,2,0,0,3,0,2,2,1,1,1,4,2,1,3,2,4,0,0,1,5,3,4,2,5,4,2,1,1,2,7,4,6,1,4,2,1,5,0,2,5,2,0,2,3,4,4,1,0,1,0,4,1,4,3,2,2,3,3,2,3,3,3,2,0,1,3,4,3,2,5,3,1,6,2,4,2,3,2,2,2,3,1,2,2,0,6,2,0,2,4,5,1,1,0,2,5,1,2,4,3,4,2,0,2,1,3,4,3,3,3,2,3,5,2,6,2,3,1,3,4,3,1,2,1,3,5,4,0,2,1,1,0,1,1,3,3,3,2,3,2,2,0,2,2,1,4,1,2,4,2,3,1,2,2,3,1,5,0,4,6,1,0,2,5,3,3,5,0,3,1,3,3,7,3,3,1,5,4,4,6,1,6,1,5,2,1,1,1,1,1,4,2,3,5,2,2,1,4,1,1,2,2,1,2,0,0,3,4,2,0,2,4,5,3,3,1,1,1,2,3,1,2,1,4,2,1,3,0,0,2,2,0,0,0,1,1,0,0,0,0,4,4,3,1,1,1,2,1,0,1,1,1,2,4,2,3,3,1,1,2,4,0,1,1,1,2,2,4,2,2,3,2,2,3,1,1,1,5,4,3,4,1,3,3,1,1,1,0,4,3,5,3,1,4,2,3,1,2,3,5,2,3,2,2,4,1,2,5,2,1,4,1,2,3,4,3,2,3,2,2,3,1,0,0,0,1,2,2,0,3,1,2,1,0,0,4,1,0,4,0,0,1,3,0,0,1,1,1,2,0,3,2,0,2,1,3,3,0,2,0,1,2,4,4,3,1,3,5,1,3,0,1,1,3,4,1,0,3,1,3,4,1,3,2,3,3,1,3,3,3,4,7,2,4,3,4,1,2,0,2,3,1,2,3,2,3,0,2,2,2,7,2,3,1,1,3,2,1,3,3,1,1,1,2,4,1,2,6,1,0,0,3,0,4,0,0,2,2,3,3,1,2,0,3,1,1,3,1,5,1,2,4,2,1,1,3,6,5,2,0,4,2,5,0,2,1,4,2,2,3,2,1,7,3,5,0,4,3,0,2,1,3,2,2,1,1,0,2,1,4,2,2,1,1,2,4,3,1,0,1,2,1,2,0,1,5,0,3,3,2,2,1,0,2,1,5,0,5,1,3,2,3,2,1,3,2,3,0,0,2,2,1,1,2,0,0,3,3,2,2,0,5,2,5,2,2,4,1,2,5,0,3,4,2,1,5,3,1,5,3,0,1,3,4,5,2,2,2,4,1,1,1,3,0,2,1,5,1,1,1,1,1,4,3,1,1,1,1,4,3,0,2,5,2,4,3,1,0,3,2,4,5,1,4,2,2,4,2,3,3,4,3,1,3,1,1,4,1,3,1,2,0,4,4,3,3,2,1,2,1,1,2,0,3,1,2,3,2,1,1,0,1,1,0,1,2,3,1,3,3,1,0,2,4,1,2,3,2,1,1,2,2,1,0,1,1,3,0,0,0,3,3,3,1,0,4,0,3,1,3,1,1,4,2,3,0,1,2,2,1,0,0,4,1,3,0,3,2,2,2,0,1,1,1,1,2,3,2,0,1,2,0,2,2,1,0,1,0,2,3,2,2,1,3,3,0,2,3,2,2,6,3,1,1,0,2,2,1,0,2,3,1,0,0,1,2,1,1,0,0,1,0,1,0,2,2,3,1,2,0,1,0,0,3,0,0,2,1,2,0,2,0,2,1,3,0,0,2,3,0,1,0,0,0,0,2,1,0,4,2,0,1,0,3,3,2,0,1,2,2,1,1,1,0,1,2,2,1,3,2,1,3,0,1,1,2,2,0,2,0,2,2,1,1,2,1,0,0,1,1,2,1,0,2,1,3,2,0,2,4,2,1,0,2,1,1,1,0,2,2,1,2,2,3,0,1,2,2,3,3,2,1,2,2,5,0,0,3,1,3,0,1,1,3,3,2,2,1,0,2,3,4,2,4,2,1,1,2,1,0,2,1,0,1,2,1,3,1,4,2,4,1,3,3,2,1,3,0,3,1,3,0,1,3,4,1,5,0,4,2,4,6,3,1,1,1,4,2,2,5,3,0,0,2,1,1,0,1,2,3,0,3,0,1,2,0,4,1,2,2,2,4,1,1,0,4,1,1,1,3,0,1,2,3,2,2,4,1,2,1,1,2,3,2,2,2,1,0,0,0,0,3,0,1,2,0,1,2,1,2,1,0,2,4,3,4,4,3,4,3,3,3,2,0,0,0,1,3,1,2,4,0,4,4,1,1,3,1,1,1,0,1,1,2,2,3,0,1,0,3,0,0,1,2,1,2,2,4,1,1,3,1,0,0,3,1,2,1,1,2,3,2,3,0,0,7,2,2,0,3,1,0,3,1,1,1,2,1,3,0,4,1,1,0,2,4,1,2,1,0,4,2,2,0,1,1,2,1,1,0,1,1,1,3,0,1,1,1,4,2,0,2,2,2,2,4,0,2,1,1,3,0]},"production-gce-c28":{"authFailuresPerMinute":[2,0,0,0,0,0,6,4,3,3,5,3,2,5,5,3,2,1,0,1,1,0,0,1,1,3,2,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,2,0,0,0,0,0,2,0,1,0,0,0,1,0,0,0,1,1,0,2,0,1,0,0,0,0,1,1,3,0,1,1,2,1,1,0,0,0,1,7,3,5,4,5,6,3,0,1,3,2,2,1,1,0,0,0,0,0,0,1,0,1,0,1,0,0,0,2,0,0,0,0,0,3,0,0,1,1,0,0,2,0,0,1,3,1,4,5,1,5,6,3,5,2,0,0,0,0,0,8,4,4,8,8,10,7,7,4,7,0,1,1,1,0,0,0,0,0,0,1,0,1,0,0,1,1,0,1,1,5,12,8,5,4,3,5,5,3,3,0,1,3,3,0,0,1,1,2,2,1,0,0,0,1,0,2,0,1,0,1,0,0,0,0,0,0,1,1,0,0,4,2,0,2,0,0,0,0,0,0,0,0,0,3,0,0,1,0,0,1,0,0,2,5,3,0,0,0,0,1,0,0,0,1,1,2,2,2,1,0,0,0,3,0,0,1,0,0,0,0,0,0,0,1,0,2,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,1,0,1,0,2,0,0,0,0,0,0,2,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,1,0,1,0,0,0,0,0,0,0,0,4,3,2,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,1,1,2,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,2,8,4,3,2,1,1,0,1,0,0,2,0,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,0,0,1,1,0,7,2,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,2,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,2,0,1,1,1,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,2,1,0,0,2,0,1,0,0,0,0,1,0,1,0,1,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,1,2,1,0,0,0,0,1,0,0,0,0,2,0,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,0,3,7,3,2,0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,1,1,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,5,0,0,0,2,0,0,1,1,0,0,0,0,0,0,2,4,2,6,2,3,2,3,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,1,1,0,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,6,2,0,0,0,1,1,0,0,0,0,0,0,1,0,1,2,0,0,1,1,1,0,2,1,0,0,1,0,1,0,0,0,1,0,0,0,1,0,0,0,0,0,1,1,2,0,1,1,2,0,0,0,2,0,0,1,1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,3,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,2,0,0,0,0,1,1,0,0,0,0,1,0,1,1,2,1,1,1,0,1,0,0,1,0,0,1,0,0,1,0,1,1,0,2,1,0,0,0,1,0,0,2,0,0,1,0,0,0,0,2,2,0,0,0,1,0,0,1,2,0,0,0,2,0,0,1,0,2,0,1,3,0,0,0,1,0,1,0,0,0,0,1,2,3,0,0,0,0,0,1,0,0,1,0,1,0,0,6,2,0,5,2,0,0,0,4,5,3,1,0,5,4,0,0,3,0,1,0,1,0,5,0,2,3,6,3,6,2,0,5,3,9,0,0,3,0,1,6,3,0,2,0,5,1,1,1,4,1,1,1,0,0,0,0,1,0,1,2,1,0,5,4,5,2,0,1,0,0,0,1,1,6,5,3,6,8,6,4,9,3,6,3,3,0,3,14,5,11,6,5,11,8,5,6,10,4,1,9,14,7,4,14,6,5,4,4,1,9,9,11,6,1,0,0,2,0,0,0,1,1,0,0,0,1,0,0,1,3,3,1,0,2,2,1,1,0,0,0,0,3,4,0,3,0,2,0,3,1,2,0,0,0,1,1,0,0,1,2,1,2,1,11,5,3,0,0,1,8,8,1,4,4,0,4,0,1,6,7,6,5,9,5,3,3,5,2,7,1,2,3,1,0,1,0,1,0,2,1,5,4,5,2,1,0,0,4,2,0,0,1,0,1,0,0,0,1,1,1,0,0,0,0,0,5,6,0,1,5,2,1,0,0,0,1,2,2,1,0,0,0,2,5,2,0,0,0,0,0,0,0,0,1,2,0,0,0,1,1,0,0,0,2,0,0,0,1,0,4,2,0,1,0,2,1,1,9,2,0,0,0,1,1,0,0,1,3,2,4,3,6,2,2,1,1,0,0,2,1,0,3,6,0,2,1,0,0,0,2,1,2,5,1,2,0,3,0,0,1,0,0,0,1,0,2,2,0,0,0,1,1,0,0,0,0,0,0,1,0,1],"completedAcceptsPerMinute":[17,10,8,8,13,10,8,10,16,16,31,20,15,17,14,14,17,19,22,20,20,12,15,23,12,16,11,20,21,19,23,17,19,25,18,19,13,12,16,8,11,10,15,15,19,6,9,7,7,11,14,16,15,14,9,10,11,7,10,5,12,12,11,12,6,7,10,6,10,4,10,7,10,7,7,13,14,5,11,4,9,7,9,9,10,17,11,14,16,9,15,12,12,1,8,8,9,7,10,17,14,13,7,10,16,13,8,21,18,13,14,7,8,14,10,14,12,14,16,19,16,22,21,15,19,18,16,16,9,8,14,9,10,14,10,12,14,8,7,13,9,9,10,8,9,7,14,16,15,11,17,6,8,18,6,12,10,5,11,11,8,10,8,7,13,16,8,9,13,9,15,8,18,14,13,9,9,5,7,9,3,8,8,6,7,13,15,17,12,8,9,7,10,11,15,16,12,15,16,16,14,9,6,6,16,11,10,12,16,14,18,11,11,16,11,8,8,2,12,7,10,8,8,13,3,8,8,15,11,6,9,7,11,14,7,15,7,6,6,10,15,8,10,10,10,10,8,7,5,5,11,9,18,12,10,19,16,12,14,8,9,6,10,20,11,10,12,22,11,20,10,17,6,14,19,12,14,7,18,12,9,8,8,11,10,9,7,1,6,6,11,6,6,9,8,13,5,8,16,17,10,8,12,11,8,16,8,9,12,10,7,5,6,8,13,12,8,7,13,6,5,12,11,8,10,14,6,13,11,11,7,10,8,2,2,6,4,9,9,9,7,8,13,6,6,10,6,6,6,5,2,9,7,4,9,10,5,9,7,9,7,4,6,7,13,10,8,6,10,7,8,14,7,9,6,5,7,13,9,14,9,5,12,17,13,19,21,8,15,12,10,18,13,12,11,13,9,8,9,13,8,12,7,6,8,3,4,4,4,9,5,10,8,4,12,13,19,10,8,9,13,4,5,4,4,1,8,9,2,5,7,7,12,10,6,7,4,9,5,9,8,13,11,10,6,11,10,8,6,15,14,9,20,24,17,12,10,6,10,8,10,6,8,5,4,3,12,9,3,7,9,5,5,4,11,6,8,7,6,7,13,4,5,5,9,6,8,4,14,9,11,12,9,5,10,6,6,7,9,7,8,11,5,6,6,9,5,8,11,7,3,5,6,7,7,9,5,12,8,8,7,5,8,5,5,10,4,10,6,6,6,2,3,7,5,12,4,3,7,6,5,5,7,10,2,7,8,12,5,7,11,5,6,8,6,12,10,4,10,7,9,7,12,14,11,14,7,11,4,9,5,7,5,4,6,13,9,3,5,6,6,12,3,4,5,9,11,5,5,4,9,11,16,9,9,13,7,4,5,9,4,5,5,7,4,4,4,1,3,4,11,7,4,3,4,3,5,2,4,3,4,4,3,3,5,4,5,6,7,3,3,2,5,2,6,4,4,3,2,2,4,6,4,1,6,7,2,2,3,4,4,1,2,3,3,4,3,3,6,2,2,4,3,3,1,6,3,1,3,0,7,3,3,2,1,1,1,3,4,1,0,1,1,4,1,1,2,3,4,3,2,2,3,1,4,4,2,0,2,1,0,2,3,1,3,1,1,3,5,3,3,3,2,1,2,6,1,0,1,4,0,5,6,3,2,0,5,1,4,1,0,0,1,4,2,2,4,0,4,0,1,1,1,1,0,1,2,2,2,1,0,1,2,2,5,3,0,1,3,0,3,2,2,4,1,2,1,1,1,2,1,1,2,1,2,4,1,2,0,1,1,0,1,0,1,3,1,1,1,0,2,0,1,0,1,0,0,0,0,3,0,3,3,0,2,1,0,1,0,2,4,6,1,0,3,1,0,0,2,0,2,1,1,2,2,1,0,0,4,1,0,4,2,2,3,1,0,3,1,2,4,2,3,3,1,1,3,5,0,2,3,4,2,0,0,1,1,0,2,1,2,1,0,1,0,1,0,1,1,0,0,2,1,2,2,1,1,1,1,1,0,1,0,0,0,0,0,0,0,0,3,0,1,4,0,1,2,0,0,2,0,0,0,1,0,0,0,1,1,0,1,5,1,0,0,0,0,1,2,1,1,1,1,1,1,0,1,1,3,2,1,1,2,0,2,0,3,0,2,1,0,1,1,5,4,3,5,1,0,2,4,1,1,3,5,0,2,0,2,0,2,3,1,1,3,2,2,3,4,3,2,1,4,2,1,2,2,2,2,1,4,1,3,4,2,2,4,2,3,3,3,0,3,2,2,3,3,6,5,7,8,3,2,3,3,2,2,4,2,4,5,1,1,2,2,4,3,4,2,2,4,6,7,5,4,2,3,2,3,6,4,5,7,5,2,9,4,5,7,7,6,5,8,9,7,6,6,6,5,9,9,7,8,4,6,7,4,2,2,5,5,9,9,4,4,3,6,2,8,4,6,8,3,2,3,6,6,11,6,8,1,5,2,3,11,7,0,6,5,3,5,8,6,6,8,6,5,5,9,11,6,7,6,5,5,5,6,7,6,2,5,7,6,7,5,9,2,6,19,4,2,4,6,7,8,6,11,5,11,7,5,7,10,7,7,1,10,10,5,7,12,8,9,12,11,9,7,11,8,8,5,12,10,8,5,8,5,11,3,7,7,5,5,5,7,3,8,5,7,11,9,9,5,8,10,8,11,20,16,16,20,15,14,13,16,13,14,15,12,16,13,13,12,8,6,6,6,7,6,8,6,12,11,5,5,7,10,3,5,9,8,4,8,5,9,4,11,7,10,11,12,7,7,14,9,14,8,6,10,7,7,10,6,11,11,6,5,11,5,9,11,10,9,8,10,10,10,7,8,10,11,10,10,7,12,9,13,11,12,9,13,8,16,10,10,5,5,7,13,10,7,10,6,10,3,9,9,14,6,9,11,4,14,12,11,8,7,15,6,16,12,9,13,14,10,21,13,8,16,11,10,13,4,8,12,7,16,14,9,12,11,10,7,13,13,11,11,13,6,7,9,13,8,9,7,11,12,7,9,9,10,13,9,12,6,8,8,8,12,8,12,2,6,1,10,9,13,13,10,2,11,12,11,7,13,10,3,8,11,7,12,8,10,7,16,17,11,7,14,7,5,8,3,8,7,11,8,6,9,11,11,7,7,5,10,9,14,4,8,10,8,17,9,6,6,5,9,11,5,10,11,10,11,10,12,16,8,8,2,9,4,9,6,13,6,7,4,11,10,4,9,9,11,6,13,10,6,10,12,12,13,16,6,8,5,7,6,4,7,13,5,10,6,5,5,9,7,6,7,11,10,5,6,7,9,3,8,8,14,7,10,11,5,8,10,14,15,16,17,9,9,9,14,14,20,13,14,8,9,5,8,14,13,19,24,13,14,15,14,11,14,13,15,14,7,7,10,9,8,17,16,14,20,12,20,14,15,15,9,5,10,5,10,11,7,9,8,4,5,6,5,9,11,5,6,2,5,6,8,13,4,9,4,8,4,4,8,5,10,9,9,5,14,14,11,11,8,8,4,12,10,13,13,9,10,10,9,5,4,7]},"production-gce-c29":{"authFailuresPerMinute":[0,1,0,1,0,0,4,3,2,5,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,1,0,1,0,0,1,1,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,2,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,9,6,0,0,4,3,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,1,3,0,0,1,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,4,0,0,4,5,0,0,0,0,1,5,1,5,2,4,4,5,2,3,1,1,6,3,4,4,1,0,0,0,0,3,7,1,0,0,0,0,0,6,1,1,7,0,6,5,2,0,0,1,0,0,0,0,0,0,3,4,0,1,4,0,0,0,0,1,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,0,1,1,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,1,0,0,0,1,1,0,2,0,0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,3,0,1,0,0,0,2,0,0,3,4,5,3,1,0,0,0,0,0,2,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,4,3,0,0,2,2,0,0,2,2,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,2,2,0,2,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,1,0,0,2,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1,1,0,2,0,0,0,0,1,0,0,1,0,0,0,0,3,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,2,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,1,0,0,0,0,0,0,0,0,1,0,1,0,0,1,1,0,0,1,0,0,0,0,2,0,6,4,2,0,0,1,0,2,1,0,2,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,1,0,0,1,4,1,0,0,3,1,0,1,0,0,0,6,2,6,5,8,4,0,0,0,6,4,8,4,3,0,1,0,0,1,0,1,0,1,0,0,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,2,3,0,0,0,0,0,0,2,0,1,0,1,0,1,0,0,1,0,2,0,0,2,0,0,1,0,0,0,0,0,0,2,6,3,7,0,1,0,0,0,1,0,2,2,1,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,1,0,6,4,0,0,0,0,0,3,1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1,2,0,0,0,2,0,0,1,1,0,0,0,0,0,2,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1,2,1,1,3,0,2,0,0,0,1,2,0,0,0,1,0,0,1,2,0,0,0,0,3,0,0,0,0,3,0,0,0,2,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,3,2,0,0,2,0,0,0,1,1,0,0,1,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,2,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,1,1,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1,0,1,1,0,0,0,0,1,0,0,1,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1,0,0,2,1,0,0,0,0,1,0,0,1,0,0,2,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,0,2,0,0,0,2,0,1,0,1,0,0,1,0,0,0,2,0,0,0],"completedAcceptsPerMinute":[3,7,7,8,8,7,4,8,12,4,9,8,10,8,6,5,11,8,10,12,6,7,11,7,12,7,6,11,10,7,9,8,3,10,10,9,6,4,11,9,9,10,16,4,11,11,7,7,9,8,7,11,10,9,5,6,6,7,12,12,7,6,12,6,11,18,3,9,7,8,8,4,10,5,13,10,8,12,8,13,14,3,7,5,7,8,8,4,4,8,11,3,9,9,8,3,7,4,3,7,5,5,9,8,6,7,6,3,9,9,6,10,5,7,15,5,9,11,10,14,2,7,8,10,12,7,19,8,9,9,5,7,5,5,8,11,9,10,8,5,4,11,7,8,13,10,15,12,5,11,15,9,12,8,9,3,11,9,11,9,7,15,11,8,5,8,6,8,8,1,7,5,10,9,7,7,7,9,11,7,13,10,7,9,9,4,6,7,9,7,7,5,10,13,8,6,4,11,5,9,9,7,2,9,1,8,5,8,10,12,10,12,4,8,4,8,7,9,7,3,4,4,3,9,3,8,12,5,6,12,15,8,16,14,9,9,12,5,15,7,8,6,7,11,9,12,3,6,6,7,6,7,3,2,7,10,9,16,2,14,21,11,15,12,10,6,13,1,12,7,15,5,7,11,15,6,2,10,9,10,15,13,14,12,17,12,11,13,8,4,14,10,7,3,4,6,3,5,9,8,10,10,3,7,8,6,8,6,8,12,7,4,9,7,9,5,8,6,10,5,3,6,6,5,3,8,12,5,8,7,12,4,14,13,12,9,14,16,13,14,11,7,8,4,3,8,9,3,5,5,12,7,8,6,11,10,12,13,7,14,17,16,13,15,17,9,14,7,6,9,9,9,11,8,8,7,8,2,12,7,10,6,7,5,5,7,11,7,11,7,3,5,4,9,8,7,9,5,7,8,7,9,5,9,16,15,23,20,14,11,10,5,4,13,14,13,10,16,15,20,16,21,16,17,20,15,16,21,19,13,12,9,11,10,7,12,12,4,9,7,4,14,9,14,15,16,14,9,7,3,6,8,9,6,12,7,12,5,12,10,13,6,10,8,10,6,5,4,8,6,13,6,10,9,6,8,7,7,12,6,5,6,10,12,15,14,9,12,13,17,13,4,3,4,6,8,6,8,9,8,13,3,2,3,6,6,10,6,3,10,4,8,13,5,2,4,2,12,5,6,5,9,8,7,5,7,4,4,5,9,12,14,15,9,8,2,4,6,6,2,6,4,6,1,6,5,4,3,3,6,7,1,3,2,6,1,5,9,5,3,1,4,4,5,6,11,5,4,4,4,4,4,6,4,3,6,5,6,4,4,2,7,6,2,7,4,7,6,4,3,3,4,8,5,0,3,5,4,1,5,1,2,2,5,1,8,3,2,6,1,2,3,5,3,2,4,6,5,3,2,3,2,1,3,2,5,4,3,1,3,2,5,4,2,5,1,2,3,3,2,0,3,0,1,3,5,3,1,4,1,6,3,4,2,2,4,2,2,4,2,3,2,5,4,3,2,2,1,6,10,9,10,7,4,1,2,6,8,18,14,15,16,15,19,15,17,13,18,14,8,3,1,1,0,5,1,3,1,0,0,1,0,3,2,0,1,1,6,6,3,3,3,1,2,2,2,4,2,0,1,4,2,0,1,1,0,1,1,2,1,4,1,4,1,6,3,1,1,2,3,1,0,1,2,0,0,1,0,4,2,1,2,1,1,1,1,3,2,0,1,4,0,0,5,0,1,1,2,1,1,3,2,2,3,3,3,2,0,3,2,1,3,0,2,4,3,3,5,5,3,1,0,2,1,2,0,0,1,4,2,0,1,2,2,2,1,1,1,1,2,2,4,2,3,1,5,1,2,3,3,1,0,1,1,1,0,0,2,2,0,1,3,3,4,2,0,0,2,1,2,2,0,1,1,0,0,0,0,1,1,1,2,0,2,0,2,0,1,0,1,3,1,2,1,0,0,2,1,0,1,1,2,0,1,1,1,0,0,0,1,2,1,2,2,0,0,1,1,1,4,0,1,0,1,5,2,0,2,0,1,1,2,3,1,1,1,2,2,2,3,2,0,2,1,1,0,0,2,4,2,3,0,0,0,0,2,8,2,1,1,1,1,1,1,2,0,2,0,1,1,0,0,0,1,2,1,3,2,3,1,1,3,4,5,0,3,1,2,0,3,2,4,1,3,3,3,2,4,1,5,5,6,1,3,1,2,2,0,4,13,5,8,3,2,2,1,4,2,3,2,4,0,4,5,2,7,5,0,6,5,6,4,3,8,7,4,2,5,3,12,11,12,7,5,11,17,15,12,11,7,6,4,3,8,3,3,4,2,2,2,5,8,4,7,5,4,1,5,3,2,6,2,5,7,16,3,5,4,11,6,12,7,8,6,8,3,7,2,3,6,8,2,5,11,6,6,7,7,4,7,7,11,4,5,3,7,7,0,3,8,5,9,11,4,7,4,15,5,4,2,6,5,7,4,5,10,3,5,10,6,10,6,8,5,8,6,10,9,12,5,7,12,6,8,5,7,9,8,6,6,15,12,17,13,8,14,5,5,9,7,4,8,9,7,10,6,8,7,10,9,10,6,8,10,4,3,9,4,11,4,5,6,9,9,10,8,6,8,5,8,4,4,5,11,3,2,5,3,4,3,5,7,4,8,1,5,6,7,6,14,11,9,11,7,6,8,12,4,10,6,9,13,12,11,2,7,11,7,10,7,8,9,8,9,8,12,18,12,9,14,13,14,16,12,13,8,8,14,14,3,12,5,15,9,12,9,6,8,15,11,9,5,8,11,12,8,8,6,4,10,6,7,17,12,8,9,11,11,8,10,11,6,13,9,11,15,11,12,6,8,9,11,14,7,10,7,10,6,7,13,5,10,14,16,15,20,9,7,7,10,9,13,10,6,10,10,15,16,18,16,12,6,7,10,9,6,7,15,10,6,13,14,25,10,10,8,9,10,8,7,12,13,6,5,6,8,7,12,8,12,11,14,6,10,10,13,11,14,14,9,16,14,10,5,6,8,6,12,8,8,7,8,9,7,7,14,7,7,8,8,4,7,5,6,8,9,24,10,12,14,10,12,9,6,10,14,6,13,7,16,8,9,13,10,15,11,14,14,11,10,16,9,12,9,9,11,6,10,13,4,13,11,10,6,9,11,10,6,7,8,9,7,7,6,7,7,8,10,12,6,12,10,10,6,7,9,11,5,3,6,6,6,10,9,8,9,7,7,14,5,7,7,3,4,11,8,5,10,3,6,12,4,6,4,9,7,7,4,13,6,7,10,7,9,5,8,2,9,8,9,7,6,6,10,10,13,15,8,7,7,11,4,6,9,4,17,12,17,15,12,13,5,13,12,6,18,10,12,8,8,3,6,12,7,4,9,6,8,5,5,18,9,13,15,8,6,7,6,6,8,9,9,11,6,7,3,8,5,4,2,2,9,7,4,6,8,6,12,3,7,11,7,4,10,11,8,4,9,12,15,10,8,8,5,6,7,5,3,7,6,3,3,10,8,3,7,12,9,6,4]}}} diff --git a/cloud/dev/scripts/relay-reserve-cell-refusal.mjs b/cloud/dev/scripts/relay-reserve-cell-refusal.mjs new file mode 100644 index 000000000000..b91dcb5203d0 --- /dev/null +++ b/cloud/dev/scripts/relay-reserve-cell-refusal.mjs @@ -0,0 +1,16 @@ +// Step 5: the director refuses per-cell admin reads for a reserve cell with exactly this body; +// any other 404 stays a failure. +export const RESERVE_CELL_REFUSAL_ERROR = 'cell_in_reserve_mode' + +export function isReserveCellRefusal(status, body) { + return status === 404 && body?.error === RESERVE_CELL_REFUSAL_ERROR +} + +// Step-5 design default 4: a reserve cell is flipped back to db before a roll. +export function reserveCellRollRefusal(cellId) { + return new Error( + `${cellId ?? 'a cell this step reads'} is in reserve mode ` + + `(${RESERVE_CELL_REFUSAL_ERROR}): flip it to admitMode=db with the cell-flags ` + + 'workflow and wait for it to record db before rolling it, then flip it back to reserve' + ) +} diff --git a/cloud/dev/scripts/relay-reserve-watch-rules.mjs b/cloud/dev/scripts/relay-reserve-watch-rules.mjs index 60f40f3b69ef..5ade25766ad6 100644 --- a/cloud/dev/scripts/relay-reserve-watch-rules.mjs +++ b/cloud/dev/scripts/relay-reserve-watch-rules.mjs @@ -33,6 +33,80 @@ export const RESERVE_WATCH_RULES = { // sample line's `rules` keys follow this order too. export const RULE_NAMES = ['deadMan', 'overrun', 'director503', 'duplicateSeat', 'hostDrop', 'ledgerDrift'] +// Step 6 design ยง7: judged only while the cell's switch file says phonePath=memory, against the +// cell's own runtime metrics for the 10 minutes before phonePath went memory. A trip turns the +// phone path off (phonePath=db) and the watch goes on with the reserve rules. +export const PHONE_WATCH_RULES = { + // Each rule trips on three complete minutes in a row over its line. + consecutiveMinutes: 3, + readMinutes: 10, + // Judged as a share of the minute's phone attempts (the signal plus completed accepts; for + // confirm failures, the accepts), not a count: a count line false-tripped 33/121 5-hour watches + // on busy c28 (10-18/19), where bursts of refusals arrive with the evening rise in accepts. A + // minute with fewer attempts is not judged, so low-volume c7 (~2 accepts/min) is mostly unjudged. + minAttemptsPerMinute: 4, + // Over half of the minute's attempts, and 0.25 above the share before the flip, capped so two + // failures per accept always trips. Exactly half is not enough: c28 (04:08Z) and c7 (09:13Z) + // both show 3 minutes at 50% of host-and-phone auth failures. + shareFloor: 0.5, + shareOverBackground: 0.25, + shareCeiling: 0.6, + // Page only: confirms still work, only their audit rows or renewals are lost. + pageAfterMs: 5 * MINUTE_MS +} +export const PHONE_RULE_NAMES = [ + 'phoneCredentialRefusals', + 'phoneConfirmFailures', + 'phoneAttachDeadlines', + 'phoneAcceptDatabaseErrors' +] +const PHONE_RULE_SIGNALS = { + phoneCredentialRefusals: 'credentialRefusals', + phoneConfirmFailures: 'confirmFailures', + phoneAttachDeadlines: 'attachDeadlines', + phoneAcceptDatabaseErrors: 'acceptDatabaseErrors' +} + +const sum = (values) => values.reduce((total, value) => total + value, 0) + +// The share line for a signal whose share of attempts before the flip was `backgroundShare`. +export function phoneShareLine(backgroundShare) { + const { shareFloor, shareOverBackground, shareCeiling } = PHONE_WATCH_RULES + return Math.min(shareCeiling, Math.max(shareFloor, backgroundShare + shareOverBackground)) +} + +// A completed accept makes one confirm, so confirm failures are a share of accepts (capped at +// all of them); every other signal is an attempt that did not complete. +function attemptsAndShare(signal, count, completedAccepts) { + if (signal === 'confirmFailures') { + return { attempts: completedAccepts, share: completedAccepts === 0 ? 0 : Math.min(count, completedAccepts) / completedAccepts } + } + const attempts = count + completedAccepts + return { attempts, share: attempts === 0 ? 0 : count / attempts } +} + +// Over the line: enough attempts that minute, and strictly more than the line refused or failed. +export function phoneMinuteOver(signal, count, completedAccepts, line) { + const { attempts, share } = attemptsAndShare(signal, count, completedAccepts) + return attempts >= PHONE_WATCH_RULES.minAttemptsPerMinute && share > line +} + +// The 10 minutes before phonePath went memory, from `readCellPhoneMinutes`: each signal's share of +// attempts. Null until every minute is metered. +export function measuredPhoneBackground(read) { + if (!read.ok || read.unmetered.length > 0 || read.minutes.length === 0) return null + const accepted = sum(read.series.completedAccepts) + return { + share: Object.fromEntries( + Object.values(PHONE_RULE_SIGNALS).map((signal) => { + const count = sum(read.series[signal]) + return [signal, attemptsAndShare(signal, count, accepted).share] + }) + ), + minutes: read.minutes.length + } +} + export function initialWatchState({ cellId, startedAt, durationMinutes, background503PerMinute, flippedAt }) { return { v: 1, @@ -50,7 +124,22 @@ export function initialWatchState({ cellId, startedAt, durationMinutes, backgrou hosts: [], reserveSeenAt: null, ledgerAboveSince: null, - seatBehindRowFirstSeen: {} + seatBehindRowFirstSeen: {}, + // Step 6: what a trip writes back, and the phone rules' state. `phoneSince` is when the switch + // file went phonePath=memory (flippedAt if it already said so); `phoneOffAt` when the watch + // turned it off; `phoneTrip` a phone verdict the segment has yet to write. + flip: null, + lastSampleAt: null, + phoneSince: null, + phoneBackground: null, + phoneOffAt: null, + phoneTrip: null, + phoneOffFailures: 0, + phoneUnverifiableStreak: 0, + auditDropsLast: null, + auditDropsSince: null, + renewalFailuresSince: null, + paged: [] } } @@ -71,6 +160,8 @@ const trip = (reason, detail = {}) => ({ status: 'trip', reason, ...detail }) const unverifiable = (reason) => ({ status: 'unverifiable', reason }) // Not readable yet by design, so it does not count toward the unverifiable streak. const pending = (reason) => ({ status: 'pending', reason }) +const failedPhoneRead = { ok: false, error: 'no cell phone metrics read' } +const minuteFloor = (at) => Math.floor(at / MINUTE_MS) * MINUTE_MS function judgeDeadMan(cell) { if (!cell.ok) return unverifiable(cell.error) @@ -186,6 +277,74 @@ function judgeLedgerDrift(cell, ledger, aboveSince, at) { return { verdict: pass({ ...detail, aboveForMs: at - since }), aboveSince: since } } +// A counter still rising (or failing) every sample for 5 minutes pages once; a single drop does not. +function judgeSustained(rising, since, at) { + const next = rising ? (since ?? at) : null + return { since: next, sustained: next !== null && at - next >= PHONE_WATCH_RULES.pageAfterMs } +} + +export function phoneSinceOf(cell, state) { + if (state.phoneSince !== null || !cell.ok || cell.phonePathConfigured !== 'memory') return state.phoneSince + // Memory from the first sample: the operator's flipped-at. Later: the sample before this one. + return state.lastSampleAt === null ? state.flippedAt : state.lastSampleAt +} + +// The phone rules for one sample, or none while the switch file does not say phonePath=memory. +function judgePhoneRules(cell, phone, state, at) { + if (!cell.ok || cell.phonePathConfigured !== 'memory' || state.phoneOffAt !== null) return {} + const background = state.phoneBackground + if (!background) { + const verdict = + state.phoneSince === null + ? unverifiable('the phone rules need --flipped-at: their background is the 10 minutes before it') + : at < minuteFloor(state.phoneSince) + RESERVE_WATCH_RULES.director503.publishDelayMs + ? pending('the phone background before phonePath went memory is not published yet') + : unverifiable('no phone background yet') + return Object.fromEntries(PHONE_RULE_NAMES.map((name) => [name, verdict])) + } + if (!phone.ok) return Object.fromEntries(PHONE_RULE_NAMES.map((name) => [name, unverifiable(phone.error)])) + const { consecutiveMinutes } = PHONE_WATCH_RULES + // Only the judged minutes need metrics; a gap earlier in the read changes nothing. + const judged = phone.minutes.slice(-consecutiveMinutes) + const gaps = phone.unmetered.filter((minute) => judged.includes(minute)) + if (gaps.length > 0) { + const gap = unverifiable(`no cell runtime metrics for ${gaps.join(', ')}`) + return Object.fromEntries(PHONE_RULE_NAMES.map((name) => [name, gap])) + } + const accepts = phone.series.completedAccepts.slice(-consecutiveMinutes) + return Object.fromEntries( + PHONE_RULE_NAMES.map((name) => { + const signal = PHONE_RULE_SIGNALS[name] + const line = phoneShareLine(background.share[signal]) + const recent = phone.series[signal].slice(-consecutiveMinutes) + const detail = { backgroundShare: background.share[signal], line, recent, accepts } + const over = + recent.length === consecutiveMinutes && recent.every((count, index) => phoneMinuteOver(signal, count, accepts[index], line)) + return [ + name, + over ? trip(`${signal} over ${Math.round(line * 100)}% of attempts for ${consecutiveMinutes} min (${recent.join(', ')} against ${accepts.join(', ')} accepts)`, detail) : pass(detail) + ] + }) + ) +} + +// Page-only signals: audit rows dropped since the previous sample, and renewal writes failing in +// every one of the last 5 published minutes. +function judgePhonePages(cell, phone, state, at) { + const dropped = cell.ok && Number.isInteger(cell.resumeAuditDropped) ? cell.resumeAuditDropped : null + const audit = judgeSustained( + dropped !== null && state.auditDropsLast !== null && dropped > state.auditDropsLast, + state.auditDropsSince, + at + ) + const renewals = phone?.ok ? (phone.series.renewalFailures ?? []).slice(-PHONE_WATCH_RULES.pageAfterMs / MINUTE_MS) : [] + const renewal = judgeSustained(renewals.length > 0 && renewals.every((count) => count > 0), state.renewalFailuresSince, at) + const pages = [] + if (audit.sustained && !state.paged.includes('audit-drops')) pages.push('audit-drops') + if (renewal.sustained && !state.paged.includes('renewal-failures')) pages.push('renewal-failures') + return { auditDropsLast: dropped ?? state.auditDropsLast, auditDropsSince: audit.since, renewalFailuresSince: renewal.since, pages } +} + /** * One sample. `sample.cell` is the cell's runtime-status, `director503` the complete minutes of * non-drain director 503s with the request series' publishing front (`publishedThrough`) and the @@ -220,34 +379,63 @@ export function evaluateWatchSample(state, sample, { unverifiableLimit }) { next.seatBehindRowFirstSeen = duplicate.firstSeen const ledger = judgeLedgerDrift(cell, sample.ledger, state.ledgerAboveSince, at) next.ledgerAboveSince = ledger.aboveSince + next.lastSampleAt = at + next.phoneSince = phoneSinceOf(cell, state) + const phoneState = { ...state, phoneSince: next.phoneSince } + const pageCheck = judgePhonePages(cell, sample.phone, state, at) + next.auditDropsLast = pageCheck.auditDropsLast + next.auditDropsSince = pageCheck.auditDropsSince + next.renewalFailuresSince = pageCheck.renewalFailuresSince + next.paged = [...state.paged, ...pageCheck.pages] + const pages = pageCheck.pages const rules = { deadMan: judgeDeadMan(cell), overrun: judgeOverrun(cell), director503: judgeDirector503(sample.director503, state, at), duplicateSeat: duplicate.verdict, hostDrop: cell.ok ? judgeHostDrop(next.hosts) : unverifiable(cell.error), - ledgerDrift: ledger.verdict + ledgerDrift: ledger.verdict, + ...judgePhoneRules(cell, sample.phone ?? failedPhoneRead, phoneState, at) } const tripped = RULE_NAMES.find((name) => rules[name].status === 'trip') if (tripped) { next.state = 'tripped' + next.flip = 'admitMode' next.verdict = `flip back ${state.cellId}: ${tripped}: ${rules[tripped].reason}` - return { state: next, rules, line: sampleLine(next, sample, rules) } + return { state: next, rules, line: sampleLine(next, sample, rules), pages } + } + // A phone trip turns off only the phone path; the segment writes it and the watch goes on. + const phoneNames = PHONE_RULE_NAMES.filter((name) => name in rules) + const phoneTripped = phoneNames.find((name) => rules[name].status === 'trip') + const phoneMissing = phoneNames.filter((name) => rules[name].status === 'unverifiable') + next.phoneUnverifiableStreak = phoneMissing.length > 0 ? state.phoneUnverifiableStreak + 1 : 0 + if (phoneTripped) { + next.phoneTrip = `turn off the phone path on ${state.cellId}: ${phoneTripped}: ${rules[phoneTripped].reason}` + } else if (next.phoneUnverifiableStreak >= unverifiableLimit) { + next.phoneTrip = + `turn off the phone path on ${state.cellId}: unverifiable: ${next.phoneUnverifiableStreak} samples ` + + `in a row could not read ${phoneMissing.join(', ')}` } const missing = RULE_NAMES.filter((name) => rules[name].status === 'unverifiable') - const waiting = RULE_NAMES.some((name) => rules[name].status === 'pending') + const waiting = [...RULE_NAMES, ...phoneNames].some((name) => rules[name].status === 'pending') // A pending rule holds the streak where it was: neither evidence of health nor a gap. next.unverifiableStreak = missing.length > 0 ? state.unverifiableStreak + 1 : waiting ? state.unverifiableStreak : 0 if (next.unverifiableStreak >= unverifiableLimit) { next.state = 'tripped' + next.flip = 'admitMode' next.verdict = `flip back ${state.cellId}: unverifiable: ${next.unverifiableStreak} samples in a row could not ` + `read ${missing.join(', ')}` + } else if (at >= state.deadlineAt && next.phoneTrip) { + // Still unwritten at the end: the workflow's flip back writes it, and pages if that fails too. + next.state = 'tripped' + next.flip = 'phonePath' + next.verdict = `${next.phoneTrip}; phonePath=db did not land before the watch ended` } else if (at >= state.deadlineAt) { next.state = 'completed' next.verdict = `${state.cellId} stayed healthy in reserve mode for the whole watch` } - return { state: next, rules, line: sampleLine(next, sample, rules) } + return { state: next, rules, line: sampleLine(next, sample, rules), pages } } function sampleLine(state, sample, rules) { @@ -259,6 +447,7 @@ function sampleLine(state, sample, rules) { state: state.state, unverifiableStreak: state.unverifiableStreak, rules, + ...(state.phoneTrip ? { phoneTrip: state.phoneTrip } : {}), ...(state.verdict ? { verdict: state.verdict } : {}) }) } diff --git a/cloud/dev/scripts/watch-relay-reserve-cell.mjs b/cloud/dev/scripts/watch-relay-reserve-cell.mjs index 5809b4f1f1dc..4e685ae38a26 100644 --- a/cloud/dev/scripts/watch-relay-reserve-cell.mjs +++ b/cloud/dev/scripts/watch-relay-reserve-cell.mjs @@ -40,6 +40,9 @@ import { evaluateWatchSample, initialWatchState, measuredBackground, + measuredPhoneBackground, + phoneSinceOf, + PHONE_WATCH_RULES, RESERVE_WATCH_RULES } from './relay-reserve-watch-rules.mjs' @@ -69,8 +72,11 @@ function integerIn(name, raw, min, max) { } // The flip back's request. Its generation is a placeholder: the flip back re-reads the current one. +// A phone-rule trip turns off only the phone path; anything else flips the whole cell back. Both +// remove the phonePath key, so a cell rolled back to an image without the switch takes the write. export function dbFlipRequest(values) { - return parseCellFlagsRequest({ ...values, set: 'admitMode=db', 'expected-generation': '0' }) + const set = values.flip === 'phonePath' ? 'phonePath=default' : 'admitMode=db,phonePath=default' + return parseCellFlagsRequest({ ...values, set, 'expected-generation': '0' }) } // `now` is the watch's start: a later segment of a long watch must not age flipped-at out. @@ -104,6 +110,8 @@ export function parseWatchArguments(values, now = Date.now()) { if (!values['state-file']) throw new Error('--state-file is required') return { request, + // The flag tool's own inputs, for turning the phone path off mid-watch. + flipValues: values, directorService: values['director-service'], durationMinutes: integerIn('duration-minutes', values['duration-minutes'], 5, MAX_WATCH_MINUTES), segmentMinutes: integerIn('segment-minutes', values['segment-minutes'], 1, MAX_SEGMENT_MINUTES), @@ -130,6 +138,8 @@ export async function readCellSample(fetchImpl, request, idToken, wait) { ok: true, admitModeConfigured: runtime.flagsApplied.flags?.admitMode ?? 'db', admitModeEffective: runtime.admitModeEffective, + phonePathConfigured: runtime.flagsApplied.flags?.phonePath ?? 'db', + resumeAuditDropped: runtime.runtime?.resumeAuditDropped, draining: runtime.draining === true, controls, enforcedUnits: runtime.runtime?.enforcedConnectionUnits, @@ -184,6 +194,54 @@ export async function readDirector503Minutes(reader, { projectId, directorServic } } +const CELL_METRICS_EVENT = 'orca_relay_runtime_metrics' +const PHONE_SIGNALS = ['credentialRefusals', 'confirmFailures', 'attachDeadlines', 'acceptDatabaseErrors', 'renewalFailures', 'completedAccepts'] +const sumOf = (counts) => Object.values(counts ?? {}).reduce((sum, count) => sum + Number(count), 0) + +// The cell's own runtime metrics lines (one per 30 s) for the `minutes` whole minutes ending at +// `endedAt`, per minute: what the phone rules judge. A line counts toward the minute it ends in; +// a minute no line covers is `unmetered`, never zero. +export async function readCellPhoneMinutes(reader, { cellId, endedAt, minutes }) { + const span = { startedAt: new Date(endedAt - minutes * MINUTE_MS), endedAt: new Date(endedAt) } + const read = await readLogEntries(reader, { + filter: [ + 'resource.type="gce_instance"', + `jsonPayload.event="${CELL_METRICS_EVENT}"`, + `jsonPayload.cellId="${cellId}"`, + `timestamp>="${span.startedAt.toISOString()}"`, + `timestamp<"${new Date(endedAt + DIRECTOR_METRICS_INTERVAL_MS).toISOString()}"` + ].join(' AND '), + projection: + 'json(timestamp,jsonPayload.phoneRejectionsByCodeDelta,jsonPayload.resumeConfirmFailuresByCodeDelta,' + + 'jsonPayload.phoneAttachDeadlinesDelta,jsonPayload.phoneAcceptDatabaseErrorsDelta,jsonPayload.resumeRenewalFailuresDelta,' + + 'jsonPayload.clientAcceptCompletedDelta)', + limit: LOG_ENTRY_LIMIT + }) + if (read.failed) return failedRead(read.error) + if (read.entries.length >= LOG_ENTRY_LIMIT) return failedRead('cell runtime metrics read truncated') + const keys = minutesOf(span) + const series = Object.fromEntries(PHONE_SIGNALS.map((signal) => [signal, keys.map(() => 0)])) + const metered = new Set() + for (const entry of read.entries) { + const at = Date.parse(entry.timestamp) + if (!Number.isFinite(at)) return failedRead('cell runtime metrics line with an unparseable timestamp') + const payload = entry.jsonPayload ?? {} + if (payload.phoneAttachDeadlinesDelta === undefined) { + return { ...failedRead('cell runtime metrics carry no phone counters (an image before step 6)'), permanent: true } + } + metered.add(minuteKey(at - DIRECTOR_METRICS_INTERVAL_MS)).add(minuteKey(at - 1)) + const index = keys.indexOf(minuteKey(at - 1)) + if (index < 0) continue + series.credentialRefusals[index] += Number(payload.phoneRejectionsByCodeDelta?.['4401'] ?? 0) + series.confirmFailures[index] += sumOf(payload.resumeConfirmFailuresByCodeDelta) + series.attachDeadlines[index] += Number(payload.phoneAttachDeadlinesDelta) + series.acceptDatabaseErrors[index] += Number(payload.phoneAcceptDatabaseErrorsDelta ?? 0) + series.renewalFailures[index] += Number(payload.resumeRenewalFailuresDelta ?? 0) + series.completedAccepts[index] += Number(payload.clientAcceptCompletedDelta ?? 0) + } + return { ok: true, minutes: keys, series, unmetered: keys.filter((minute) => !metered.has(minute)) } +} + const LEDGER_SUMMARY_EVENT = 'orca_relay_reserve_ledger_summary' const SEAT_BEHIND_ROW_EVENT = 'orca_relay_reserve_ledger_seat_behind_row' @@ -375,9 +433,44 @@ export async function watchSegment(config, dependencies) { } const reader = createReader(deps, request.projectId) // Serialised: concurrent Logging reads are what earn a 429. + const cell = await readCellSample(deps.fetchImpl, request, dependencies.idToken, sleep) + const phoneWatched = cell.ok && cell.phonePathConfigured === 'memory' && state.phoneOffAt === null + const phoneSince = phoneSinceOf(cell, state) + const phoneBaselineEndsAt = phoneSince === null ? null : minuteFloor(phoneSince) + if (phoneWatched && !state.phoneBackground && phoneBaselineEndsAt !== null && at >= phoneBaselineEndsAt + METRIC_DELAY_MS) { + const read = await readCellPhoneMinutes(reader, { + cellId: request.cellId, + endedAt: phoneBaselineEndsAt, + minutes: BACKGROUND_MINUTES + }) + // A restart or roll in those minutes leaves no background to judge against: no phone + // rule could ever pass, so refuse rather than turn the phone path off for certain. + const incomplete = read.ok ? read.unmetered.length > 0 && `no metrics for ${read.unmetered.join(', ')}` : read.permanent && read.error + if (incomplete) { + state = { + ...state, + state: 'refused', + verdict: + `${request.cellId}: the phone background, the 10 minutes before ${new Date(phoneBaselineEndsAt).toISOString()}, ` + + `is incomplete (${incomplete}), as after a cell restart or roll; start a new watch with a later --flipped-at` + } + await writeFile(config.stateFile, JSON.stringify(state)) + break + } + state = { ...state, phoneBackground: measuredPhoneBackground(read) } + } const sample = { at, - cell: await readCellSample(deps.fetchImpl, request, dependencies.idToken, sleep), + cell, + ...(phoneWatched + ? { + phone: await readCellPhoneMinutes(reader, { + cellId: request.cellId, + endedAt: minuteFloor(at - METRIC_DELAY_MS), + minutes: PHONE_WATCH_RULES.readMinutes + }) + } + : {}), director503: await readDirector503Minutes(reader, { projectId: request.projectId, directorService, @@ -391,6 +484,13 @@ export async function watchSegment(config, dependencies) { const result = evaluateWatchSample(state, sample, { unverifiableLimit: config.unverifiableLimit }) state = result.state log(result.line) + for (const kind of result.pages) { + await raiseAlarm( + { mode: request.mode, cellId: request.cellId, projectId: request.projectId, kind, reason: `sustained for ${PHONE_WATCH_RULES.pageAfterMs / MINUTE_MS} min` }, + { runGcloud: dependencies.runGcloud, log } + ) + } + if (state.state === 'watching' && state.phoneTrip) state = await turnPhonePathOff(config, state, at, { ...deps, log }) await writeFile(config.stateFile, JSON.stringify(state)) if (state.state !== 'watching') break await sleep(Math.max(0, at + SAMPLE_INTERVAL_MS - now())) @@ -399,8 +499,41 @@ export async function watchSegment(config, dependencies) { return state } -function writeOutcome(githubOutput, { state, verdict }) { - return appendFile(githubOutput, `state=${state}\nverdict=${(verdict ?? '').replace(/[\r\n]+/g, ' ')}\n`) +// Writes phonePath=db (the flag tool's CAS write, a plan in dry-run) and keeps watching the +// reserve rules. A write that fails keeps the trip, so the next sample retries it, and pages once +// after PHONE_OFF_PAGE_AFTER_FAILURES in a row: the cell stays watched either way. +export const PHONE_OFF_PAGE_AFTER_FAILURES = 3 +async function turnPhonePathOff(config, state, at, dependencies) { + try { + const result = await flipBack({ ...config.flipValues, flip: 'phonePath' }, dependencies) + dependencies.log(JSON.stringify({ event: 'orca_relay_reserve_watch_phone_path_off', cellId: state.cellId, verdict: state.phoneTrip, written: result.written })) + return { ...state, phoneOffAt: at, phoneTrip: null, phoneOffFailures: 0 } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const failures = (state.phoneOffFailures ?? 0) + 1 + dependencies.log(JSON.stringify({ event: 'orca_relay_reserve_watch_phone_path_off_failed', cellId: state.cellId, failures, reason })) + if (failures === PHONE_OFF_PAGE_AFTER_FAILURES) { + await raiseAlarm( + { + mode: config.request.mode, + cellId: state.cellId, + projectId: config.request.projectId, + kind: 'flip-back-failed', + flip: 'phonePath', + reason: `${state.phoneTrip}; phonePath=db failed ${failures} times: ${reason}` + }, + { runGcloud: dependencies.runGcloud, log: dependencies.log } + ) + } + return { ...state, phoneOffFailures: failures } + } +} + +function writeOutcome(githubOutput, { state, verdict, flip }) { + return appendFile( + githubOutput, + `state=${state}\nverdict=${(verdict ?? '').replace(/[\r\n]+/g, ' ')}\nflip=${flip ?? 'admitMode'}\n` + ) } // The job's outcome. A watch that ended mid-segment (a crash, a timeout) records failed, which the @@ -460,7 +593,11 @@ export async function flipBack(values, dependencies) { ) for (let attempt = 1; ; attempt += 1) { const current = await readCurrentObject(fetchImpl, base, dependencies.accessToken) - if ((current.object?.flags?.admitMode ?? 'db') !== 'reserve') { + const stillOn = + values.flip === 'phonePath' + ? (current.object?.flags?.phonePath ?? 'db') === 'memory' + : (current.object?.flags?.admitMode ?? 'db') === 'reserve' + if (!stillOn) { const result = { written: false, alreadyDb: true, generation: current.generation } record(result) return result @@ -493,7 +630,7 @@ export async function flipBack(values, dependencies) { export const ALARM_EVENT = 'orca_relay_reserve_watch_alarm' export const ALARM_LOG_NAME = 'orca-relay-reserve-watch' -export const ALARM_KINDS = ['refused', 'flip-back-failed'] +export const ALARM_KINDS = ['refused', 'flip-back-failed', 'audit-drops', 'renewal-failures'] // GitHub's workflow-command escaping, so a verdict cannot end the annotation or start another. export const escapeWorkflowCommand = (text) => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A') @@ -503,12 +640,18 @@ const escapeWorkflowProperty = (text) => escapeWorkflowCommand(text).replace(/:/ * Both kinds can leave the cell in reserve with nothing watching it. Only write mode pages: a * dry-run watch never flips back, so its refusal or failure changes nothing for the cell. */ -export async function raiseAlarm({ mode, cellId, projectId, kind, reason }, { runGcloud, log = console.log }) { +export async function raiseAlarm({ mode, cellId, projectId, kind, reason, flip }, { runGcloud, log = console.log }) { if (!ALARM_KINDS.includes(kind)) throw new Error(`--kind must be one of ${ALARM_KINDS.join(', ')}`) const message = kind === 'refused' ? `${cellId} may be in reserve mode and unwatched: the watch was refused (${reason}). Rerun it, or flip the cell to db with Operate Relay Production Cell Flags.` - : `${cellId} may still be in reserve mode with no watch: the flip back did not land (${reason}). Flip it to db by hand with Operate Relay Production Cell Flags (set=admitMode=db), or with the break-glass inputs logged above if the cell does not answer.` + : kind === 'audit-drops' + ? `${cellId} is losing resume audit rows (${reason}); phones still connect. Check its database writes, or set phonePath=db.` + : kind === 'renewal-failures' + ? `${cellId} cannot renew phone credentials (${reason}); phones still connect but age toward re-pairing. Check its database writes, or set phonePath=db.` + : flip === 'phonePath' + ? `${cellId} may still have phonePath=memory with no watch: turning it off did not land (${reason}). Set phonePath=default by hand with Operate Relay Production Cell Flags.` + : `${cellId} may still be in reserve mode with no watch: the flip back did not land (${reason}). Flip it to db by hand with Operate Relay Production Cell Flags (set=admitMode=db), or with the break-glass inputs logged above if the cell does not answer.` log(`::error title=${escapeWorkflowProperty(`Reserve watch ${kind} for ${cellId}`)}::${escapeWorkflowCommand(message)}`) if (mode !== 'write') return { paged: false } const entry = JSON.stringify({ event: ALARM_EVENT, cellId, kind, reason }) @@ -547,7 +690,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { 'state-file', 'github-output', 'verdict', - 'kind' + 'kind', + 'flip' ].map((name) => [name, { type: 'string' }]) ) }) @@ -557,7 +701,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { try { if (command === 'alarm') { await raiseAlarm( - { mode: values.mode, cellId: values['cell-id'] ?? '', projectId: values['project-id'], kind: values.kind, reason: values.verdict ?? '' }, + { mode: values.mode, cellId: values['cell-id'] ?? '', projectId: values['project-id'], kind: values.kind, reason: values.verdict ?? '', flip: values.flip }, { runGcloud } ) // The alarm step is red, whatever the page did. @@ -571,6 +715,13 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { const config = await parseWatchSegment(values) const state = await watchSegment(config, { idToken, + // For turning the phone path off mid-watch; without it that write fails and the workflow's flip back retries. + accessToken: process.env.ORCA_RELAY_CONTROL_ACCESS_TOKEN, + audit: { + writtenBy: `reserve-watch/${process.env.GITHUB_TRIGGERING_ACTOR ?? process.env.GITHUB_ACTOR ?? 'unknown'}`, + runId: process.env.GITHUB_RUN_ID ?? 'local', + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '0' + }, readRolloutLease: readProductionRolloutLease, runGcloud }) diff --git a/cloud/dev/scripts/watch-relay-reserve-cell.test.mjs b/cloud/dev/scripts/watch-relay-reserve-cell.test.mjs index 41a25eb4eadf..98c9829f8fac 100644 --- a/cloud/dev/scripts/watch-relay-reserve-cell.test.mjs +++ b/cloud/dev/scripts/watch-relay-reserve-cell.test.mjs @@ -7,7 +7,12 @@ import { test } from 'node:test' import { evaluateWatchSample, initialWatchState, - measuredBackground + measuredBackground, + measuredPhoneBackground, + phoneMinuteOver, + phoneShareLine, + PHONE_RULE_NAMES, + PHONE_WATCH_RULES } from './relay-reserve-watch-rules.mjs' import { ALARM_EVENT, @@ -18,6 +23,7 @@ import { parseWatchArguments, parseWatchSegment, raiseAlarm, + readCellPhoneMinutes, readDirector503Minutes, readSeatBehindRowSample, recordOutcome, @@ -493,7 +499,7 @@ test('a segment samples once a minute, carries state to the next segment, and st assert.equal(first.state, 'watching') assert.equal(lines.length, 3) assert.equal(first.background.source, 'measured') - assert.equal(await readFile(join(directory, 'out'), 'utf8'), 'state=watching\nverdict=\n') + assert.equal(await readFile(join(directory, 'out'), 'utf8'), 'state=watching\nverdict=\nflip=admitMode\n') const second = await watchSegment(config, dependencies) assert.equal(second.state, 'tripped') assert.equal(second.samples, 5) @@ -537,7 +543,7 @@ test('a watch that ended without a verdict records failed, which the workflow fl assert.equal((await recordOutcome(join(directory, 'watching.json'), output)).state, 'failed') await writeFile(join(directory, 'tripped.json'), JSON.stringify(freshState({ state: 'tripped', verdict: 'flip back x:\ny' }))) assert.equal((await recordOutcome(join(directory, 'tripped.json'), output)).state, 'tripped') - assert.match(await readFile(output, 'utf8'), /state=tripped\nverdict=flip back x: y\n$/) + assert.match(await readFile(output, 'utf8'), /state=tripped\nverdict=flip back x: y\nflip=admitMode\n$/) } finally { await rm(directory, { recursive: true, force: true }) } @@ -545,10 +551,20 @@ test('a watch that ended without a verdict records failed, which the workflow fl // Google Storage and the director, as far as the flag tool's flip back reaches them. `onUpload` // returns 'conflict' to play another writer landing first, optionally with its own admitMode. -function fakeFlipBack({ generation = '7', onUpload = () => 'ok', cellAnswers = true } = {}) { +function fakeFlipBack({ + generation = '7', + onUpload = () => 'ok', + cellAnswers = true, + flags = { admitMode: 'reserve', readinessLocal: true }, + supportedFlags = { + admitMode: { type: 'enum', values: ['db', 'reserve'] }, + readinessLocal: { type: 'boolean' }, + phonePath: { type: 'enum', values: ['db', 'memory'] } + } +} = {}) { const calls = [] let uploads = 0 - let object = { generation: Number(generation), flags: { admitMode: 'reserve', readinessLocal: true } } + let object = { generation: Number(generation), flags } let applied = object const fetchImpl = async (url, init = {}) => { const text = String(url) @@ -577,10 +593,7 @@ function fakeFlipBack({ generation = '7', onUpload = () => 'ok', cellAnswers = t role: 'cell', cellId: CELL, flagsApplied: applied, - supportedFlags: { - admitMode: { type: 'enum', values: ['db', 'reserve'] }, - readinessLocal: { type: 'boolean' } - }, + supportedFlags, admitModeEffective: applied.flags.admitMode, runtime: { controls: 10 } }) @@ -1054,3 +1067,346 @@ test('an alarm is an escaped error annotation everywhere, and in write mode a pa assert.match(lines.at(-2), /^::error title=Reserve watch refused for /) assert.match(lines.at(-1), /nothing pages: --project-id is not a GCP project id/) }) + +// Step 6 phone rules. A quiet window, minute by minute: ordinary 4401s from phones whose pairing +// was removed, an occasional failed confirm or half-open control. Synthetic: replaying a recorded +// production window needs read access this suite does not have. +const QUIET_PHONE_MINUTE = (index) => ({ + credentialRefusals: [0, 1, 0, 2, 1][index % 5], + confirmFailures: [0, 0, 1, 0][index % 4], + attachDeadlines: [1, 0, 2, 0, 1, 3][index % 6], + acceptDatabaseErrors: 0, + renewalFailures: 0, + completedAccepts: [10, 12, 8][index % 3] +}) +const phoneRead = (minuteCount, perMinute) => { + const minutes = Array.from({ length: minuteCount }, (_, index) => `2026-10-19T12:${String(index).padStart(2, '0')}`) + const rows = minutes.map((_, index) => perMinute(index)) + const series = Object.fromEntries(Object.keys(rows[0]).map((signal) => [signal, rows.map((row) => row[signal])])) + return { ok: true, minutes, series, unmetered: [] } +} +const memoryCell = (overrides = {}) => healthyCell({ phonePathConfigured: 'memory', resumeAuditDropped: 0, ...overrides }) +const phoneState = (overrides = {}) => + freshState({ + flippedAt: START - 10 * MINUTE, + phoneSince: START - 10 * MINUTE, + phoneBackground: measuredPhoneBackground(phoneRead(10, QUIET_PHONE_MINUTE)), + ...overrides + }) +// The last 10 minutes as of watch minute `offset`, the newest minutes shaped by `spike`. +const phoneSampleAt = (offset, spike = () => ({}), cell = memoryCell()) => + sampleAt(START + offset * MINUTE, { + cell, + phone: phoneRead(10, (index) => ({ ...QUIET_PHONE_MINUTE(offset + index), ...spike(index) })) + }) + +test('phone rules: a quiet hour replays without a trip', () => { + const results = run(Array.from({ length: 60 }, (_, offset) => phoneSampleAt(offset)), phoneState({ deadlineAt: START + 120 * MINUTE })) + assert.equal(results.length, 60) + assert.ok(results.every((result) => result.state.state === 'watching' && result.state.phoneTrip === null)) + assert.deepEqual(Object.keys(results[0].rules).filter((name) => name.startsWith('phone')), PHONE_RULE_NAMES) +}) + +test('phone rules: judged as a share of attempts, over half and over the background', () => { + assert.equal(phoneShareLine(0), 0.5) + assert.equal(phoneShareLine(0.3), 0.55) + assert.equal(phoneShareLine(0.5), 0.6) + // Exactly half is not over; nor is a minute with under 4 attempts. + assert.equal(phoneMinuteOver('credentialRefusals', 9, 9, 0.5), false) + assert.equal(phoneMinuteOver('credentialRefusals', 11, 10, 0.5), true) + assert.equal(phoneMinuteOver('credentialRefusals', 3, 0, 0.5), false) + assert.equal(phoneMinuteOver('credentialRefusals', 4, 0, 0.5), true) + // Each accept makes one confirm: every confirm failing is all of them, not half the attempts. + assert.equal(phoneMinuteOver('confirmFailures', 4, 4, 0.6), true) + assert.equal(phoneMinuteOver('confirmFailures', 2, 4, 0.5), false) + assert.equal(phoneMinuteOver('confirmFailures', 9, 3, 0.5), false) + const background = measuredPhoneBackground(phoneRead(10, () => ({ ...QUIET_PHONE_MINUTE(0), credentialRefusals: 1, completedAccepts: 9 }))) + assert.equal(background.share.credentialRefusals, 0.1) + assert.equal(measuredPhoneBackground({ ...phoneRead(10, QUIET_PHONE_MINUTE), unmetered: ['2026-10-19T12:03'] }), null) +}) + +test('phone rules: each fires on its own spike, and the watch goes on with the reserve rules', () => { + const spikes = { + phoneCredentialRefusals: { credentialRefusals: 25 }, + phoneConfirmFailures: { confirmFailures: 25 }, + phoneAttachDeadlines: { attachDeadlines: 25 }, + phoneAcceptDatabaseErrors: { acceptDatabaseErrors: 25 } + } + for (const [rule, counts] of Object.entries(spikes)) { + const [result] = run([phoneSampleAt(0, (index) => (index >= 7 ? counts : {}))], phoneState()) + assert.equal(result.state.state, 'watching', rule) + assert.match(result.state.phoneTrip, new RegExp(`turn off the phone path on ${CELL}: ${rule}: `), rule) + } + // Two minutes over the line is not three. + const [twoMinutes] = run([phoneSampleAt(0, (index) => (index >= 8 ? { credentialRefusals: 25 } : {}))], phoneState()) + assert.equal(twoMinutes.state.phoneTrip, null) + // Once the watch turned the phone path off, the phone rules stand down. + const [off] = run([phoneSampleAt(0, () => ({ credentialRefusals: 50 }))], phoneState({ phoneOffAt: START })) + assert.deepEqual(Object.keys(off.rules).filter((name) => name.startsWith('phone')), []) +}) + +test('phone rules: judged only while the switch file says memory; reserve rules still flip the cell back', () => { + const [off] = run([sampleAt(START, { cell: healthyCell({ phonePathConfigured: 'db' }) })], freshState()) + assert.deepEqual(Object.keys(off.rules).filter((name) => name.startsWith('phone')), []) + const [overrun] = run([phoneSampleAt(0, () => ({}), memoryCell({ enforcedUnits: 2_001 }))], phoneState()) + assert.equal(overrun.state.state, 'tripped') + assert.equal(overrun.state.flip, 'admitMode') +}) + +test('phone rules: missing phone data turns off only the phone path, and needs a flipped-at background', () => { + const gap = (offset) => ({ ...phoneSampleAt(offset), phone: { ...phoneSampleAt(offset).phone, unmetered: ['2026-10-19T12:09'] } }) + const results = run([gap(0), gap(1), gap(2)], phoneState()) + assert.equal(results.at(-1).state.state, 'watching') + assert.equal(results.at(-1).state.unverifiableStreak, 0) + assert.match(results.at(-1).state.phoneTrip, /unverifiable: 3 samples/) + const [noBackground] = run([phoneSampleAt(0)], freshState({ flippedAt: null })) + assert.match(noBackground.rules.phoneCredentialRefusals.reason, /--flipped-at/) + // A gap before the judged 3 minutes changes nothing. + const early = { ...phoneSampleAt(0), phone: { ...phoneSampleAt(0).phone, unmetered: ['2026-10-19T12:02'] } } + const [earlyGap] = run([early], phoneState()) + assert.equal(earlyGap.rules.phoneCredentialRefusals.status, 'pass') +}) + +test('phone rules: memory set partway through a watch measures from then, not from the reserve flip', () => { + const state = freshState({ flippedAt: START - 60 * MINUTE }) + const [before, after] = run([sampleAt(START), phoneSampleAt(1)], state) + assert.equal(before.state.phoneSince, null) + assert.equal(after.state.phoneSince, START) + assert.equal(after.rules.phoneCredentialRefusals.status, 'pending') +}) + +test('phone rules: audit drops and failing renewals page once when sustained, and flip nothing', () => { + const dropping = (offset, dropped) => phoneSampleAt(offset, () => ({}), memoryCell({ resumeAuditDropped: dropped })) + // One drop, then none: no page. + const single = run([dropping(0, 0), dropping(1, 1), ...Array.from({ length: 8 }, (_, index) => dropping(index + 2, 1))], phoneState()) + assert.ok(single.every((result) => result.pages.length === 0)) + const rising = run(Array.from({ length: 9 }, (_, offset) => dropping(offset, offset * 3)), phoneState()) + assert.deepEqual(rising.map((result) => result.pages.join()), ['', '', '', '', '', '', 'audit-drops', '', '']) + const renewing = run(Array.from({ length: 7 }, (_, offset) => phoneSampleAt(offset, () => ({ renewalFailures: 1 }))), phoneState()) + assert.deepEqual(renewing.map((result) => result.pages.join()).filter(Boolean), ['renewal-failures']) + assert.ok([...rising, ...renewing].every((result) => result.state.state === 'watching' && result.state.phoneTrip === null)) +}) + +test('the cell phone read sums each minute from the cell runtime metrics lines, and refuses an old image', async () => { + const endedAt = Date.parse('2026-10-19T12:03:00Z') + const line = (at, payload) => ({ + timestamp: new Date(at).toISOString(), + jsonPayload: { + phoneRejectionsByCodeDelta: {}, + resumeConfirmFailuresByCodeDelta: {}, + phoneAttachDeadlinesDelta: 0, + phoneAcceptDatabaseErrorsDelta: 0, + resumeRenewalFailuresDelta: 0, + clientAcceptCompletedDelta: 0, + ...payload + } + }) + let filter + const read = await readCellPhoneMinutes( + fakeReader({ + runGcloud: async (args) => { + filter = args[2] + return { + stdout: JSON.stringify([ + line(endedAt - 150_000, { phoneRejectionsByCodeDelta: { 4401: 2, 4404: 9 } }), + line(endedAt - 120_000, { resumeConfirmFailuresByCodeDelta: { 'reject-revoked': 1, error: 2 } }), + line(endedAt - 90_000, { phoneAttachDeadlinesDelta: 3, phoneAcceptDatabaseErrorsDelta: 1, resumeRenewalFailuresDelta: 4, clientAcceptCompletedDelta: 7 }) + ]) + } + } + }), + { cellId: CELL, endedAt, minutes: 3 } + ) + assert.match(filter, /resource\.type="gce_instance"/) + assert.match(filter, new RegExp(`jsonPayload\\.cellId="${CELL}"`)) + assert.deepEqual(read.series, { + credentialRefusals: [2, 0, 0], + confirmFailures: [3, 0, 0], + attachDeadlines: [0, 3, 0], + acceptDatabaseErrors: [0, 1, 0], + renewalFailures: [0, 4, 0], + completedAccepts: [0, 7, 0] + }) + assert.deepEqual(read.unmetered, ['2026-10-19T12:02']) + const old = await readCellPhoneMinutes( + fakeReader({ runGcloud: async () => ({ stdout: JSON.stringify([{ timestamp: new Date(endedAt - 1).toISOString(), jsonPayload: {} }]) }) }), + { cellId: CELL, endedAt, minutes: 3 } + ) + assert.match(old.error, /no phone counters/) + assert.equal(old.permanent, true) +}) + +// The watch over a memory cell whose phone metrics come from `phoneMetrics(at)`. +async function phoneWatch(phoneMetrics, minutes) { + const directory = await mkdtemp(join(tmpdir(), 'relay-reserve-watch-')) + try { + let clock = START + const lines = [] + const config = parseAt(watchValues({ 'state-file': join(directory, 'state.json'), 'segment-minutes': String(minutes) })) + const sources = fakeSources({ + cell: () => ({ flagsApplied: { generation: 4, flags: { admitMode: 'reserve', readinessLocal: true, phonePath: 'memory' } } }) + }) + const runGcloud = async (args) => + args[2]?.includes('gce_instance') ? { stdout: JSON.stringify(metricsLines(args[2], (at) => [phoneMetrics(at)])) } : sources.runGcloud(args) + const state = await watchSegment(config, { + ...sources, + runGcloud, + idToken: 'id', + accessToken: 'access', + audit: {}, + retryDelayMs: 0, + now: () => clock, + sleep: async (ms) => { clock += ms }, + log: (line) => lines.push(line) + }) + return { state, lines } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} +const phoneCounters = (overrides = {}) => ({ + phoneRejectionsByCodeDelta: {}, + resumeConfirmFailuresByCodeDelta: {}, + phoneAttachDeadlinesDelta: 0, + phoneAcceptDatabaseErrorsDelta: 0, + resumeRenewalFailuresDelta: 0, + clientAcceptCompletedDelta: 5, + ...overrides +}) + +test('a phone trip whose phonePath=db write fails keeps watching, retries each sample, and pages after 3 failures', async () => { + // No bucket here, so every write fails. + const { state, lines } = await phoneWatch((at) => phoneCounters(at > START ? { phoneAttachDeadlinesDelta: 20 } : {}), 15) + assert.equal(state.state, 'watching') + assert.equal(state.phoneOffAt, null) + assert.match(state.phoneTrip, /phoneAttachDeadlines: /) + const failures = lines.filter((line) => line.includes('orca_relay_reserve_watch_phone_path_off_failed')) + assert.ok(failures.length > 3, `${failures.length} failed writes`) + assert.equal(state.phoneOffFailures, failures.length) + const alarms = lines.filter((line) => line.includes('may still have phonePath=memory')) + assert.equal(alarms.length, 1) +}) + +test('a phone trip still unwritten when the watch ends leaves it to the flip back', () => { + const [last] = run([phoneSampleAt(0)], phoneState({ deadlineAt: START, phoneTrip: 'turn off the phone path on x: phoneAttachDeadlines: y' })) + assert.equal(last.state.state, 'tripped') + assert.equal(last.state.flip, 'phonePath') + assert.match(last.state.verdict, /phoneAttachDeadlines: y; phonePath=db did not land before the watch ended/) +}) + +test('a phone background broken by a restart or roll refuses the watch instead of turning phones off for certain', async () => { + const { state } = await phoneWatch((at) => (at < START - 8 * MINUTE ? {} : phoneCounters()), 5) + assert.equal(state.state, 'refused') + assert.match(state.verdict, /phone background.*incomplete.*later --flipped-at/) +}) + +test('a phone-rule flip back removes phonePath, and does nothing once the file no longer says memory', async () => { + const { fetchImpl, calls } = fakeFlipBack({ flags: { admitMode: 'reserve', readinessLocal: true, phonePath: 'memory' } }) + const result = await flipBack(flipValues({ flip: 'phonePath' }), flipDependencies(fetchImpl, [])) + assert.equal(result.written, true) + const upload = calls.find((call) => call.url.includes('/upload/storage/v1/')) + assert.match(upload.body, /"flags":\{"admitMode":"reserve","readinessLocal":true\}/) + assert.ok(!calls.some((call) => call.url.endsWith('/v1/admin/cell-admit-mode') && JSON.parse(call.body).admitMode)) + const done = await flipBack(flipValues({ flip: 'phonePath' }), flipDependencies(fakeFlipBack().fetchImpl, [])) + assert.equal(done.alreadyDb, true) +}) + +test('both flip backs land on a cell rolled back to an image without the phonePath switch', async () => { + const flags = { admitMode: 'reserve', readinessLocal: true, phonePath: 'memory' } + for (const flip of ['admitMode', 'phonePath']) { + const { fetchImpl, calls } = fakeFlipBack({ flags, supportedFlags: { admitMode: { type: 'enum', values: ['db', 'reserve'] }, readinessLocal: { type: 'boolean' } } }) + const result = await flipBack(flipValues({ flip }), flipDependencies(fetchImpl, [])) + assert.equal(result.written, true, flip) + const upload = calls.find((call) => call.url.includes('/upload/storage/v1/')) + assert.doesNotMatch(upload.body, /phonePath/, flip) + } +}) + +test('workflow: the flip back writes what the watch says tripped, and the whole cell when it says nothing', () => { + const workflow = watchWorkflow + assert.match(workflow, /flip: \$\{\{ steps\.outcome\.outputs\.flip \}\}/) + assert.match(workflow, /FLIP: \$\{\{ needs\.watch\.outputs\.flip \|\| 'admitMode' \}\}/) + assert.match(workflow, /--flip "\$\{FLIP\}"/) + // A failed phone flip back pages with the phone wording. + assert.match(workflow, /--verdict "\$\{VERDICT\}" --flip "\$\{FLIP\}"\n/) + assert.match(workflow, /ORCA_RELAY_CONTROL_ACCESS_TOKEN="\$\(gcloud auth print-access-token\)"\n\s+echo "::add-mask::/) +}) + + +test('a failed phone flip back and the phone pages say what is still on', async () => { + const lines = [] + const runGcloud = async () => ({ stdout: '' }) + await raiseAlarm({ mode: 'dry-run', cellId: CELL, projectId: 'onorca-cloud', kind: 'flip-back-failed', reason: 'x', flip: 'phonePath' }, { runGcloud, log: (line) => lines.push(line) }) + await raiseAlarm({ mode: 'dry-run', cellId: CELL, projectId: 'onorca-cloud', kind: 'renewal-failures', reason: 'y' }, { runGcloud, log: (line) => lines.push(line) }) + assert.match(lines[0], /may still have phonePath=memory/) + assert.doesNotMatch(lines[0], /reserve mode/) + assert.match(lines[1], /cannot renew phone credentials/) +}) + +// Recorded, not synthetic: 26 h of c7, c28 and c29 runtime metrics (read-only, 10-18/19). The phone +// counters are not on those images yet, so auth failures (host and phone 4401s, an over-count) +// stand in for the refused or failed attempts, against completed accepts. They cannot stand in for +// confirm failures: c28's 04:08Z minutes (9/9, 9/9, 11/10) read as confirms are a total outage. +const PHONE_RULE_SIGNAL_NAMES = ['credentialRefusals', 'confirmFailures', 'attachDeadlines', 'acceptDatabaseErrors'] +const recordedPhoneMinutes = JSON.parse(readFileSync(new URL('./relay-phone-watch-recorded-quiet-minutes.json', import.meta.url), 'utf8')).cells +// The minute index a recorded watch of `signal` from `start` trips at, or null; `minute(i)` may rewrite a minute. +function recordedTrip({ authFailuresPerMinute: failures, completedAcceptsPerMinute: accepts }, signal, start, watchMinutes, minute = (i) => [failures[i], accepts[i]]) { + const { readMinutes, consecutiveMinutes } = PHONE_WATCH_RULES + const refused = failures.slice(start - readMinutes, start) + const background = measuredPhoneBackground({ + ok: true, + minutes: refused, + unmetered: [], + series: { ...Object.fromEntries(PHONE_RULE_SIGNAL_NAMES.map((name) => [name, refused])), completedAccepts: accepts.slice(start - readMinutes, start) } + }) + const line = phoneShareLine(background.share[signal]) + let run = 0 + for (let index = start; index < start + watchMinutes; index += 1) { + run = phoneMinuteOver(signal, ...minute(index), line) ? run + 1 : 0 + if (run >= consecutiveMinutes) return index + } + return null +} + +test('phone rules: 26 recorded hours of c7, c28 and c29 trip no attempt rule in any 60- or 300-minute watch', () => { + assert.deepEqual(Object.keys(recordedPhoneMinutes), ['production-gce-c7', 'production-gce-c28', 'production-gce-c29']) + for (const [cell, minutes] of Object.entries(recordedPhoneMinutes)) { + for (const watchMinutes of [60, 300]) { + for (let start = 10; start + watchMinutes <= minutes.authFailuresPerMinute.length; start += 5) { + assert.equal(recordedTrip(minutes, 'credentialRefusals', start, watchMinutes), null, `${cell}: a ${watchMinutes}-min watch from minute ${start} trips`) + } + } + } +}) + +// From minute 30 of each recorded hour, a broken path; it must trip by its third minute (sooner +// when the minute before was already over) wherever those minutes carry 4 attempts each. +const BROKEN_PATHS = { + 'two refusals per accept': ['credentialRefusals', (failed, accepted) => [Math.max(2 * accepted, 4), accepted]], + 'every accept errors on the database, phones give up after one try': ['acceptDatabaseErrors', (failed, accepted) => [accepted, 0]], + // Only trips are tested: the auth-failure stand-in cannot measure confirm false trips. + 'every confirm fails': ['confirmFailures', (failed, accepted) => [accepted, accepted]] +} +test('phone rules: the same recorded hours trip on each broken path wherever its minutes are judged', () => { + for (const [name, [signal, breakMinute]] of Object.entries(BROKEN_PATHS)) { + for (const [cell, minutes] of Object.entries(recordedPhoneMinutes)) { + const { authFailuresPerMinute: failures, completedAcceptsPerMinute: accepts } = minutes + let judged = 0 + for (let start = 10; start + 60 <= failures.length; start += 5) { + const broken = (index) => (index >= start + 30 ? breakMinute(failures[index], accepts[index]) : [failures[index], accepts[index]]) + const judgedMinutes = [0, 1, 2].every((offset) => { + const [count, accepted] = broken(start + 30 + offset) + return (signal === 'confirmFailures' ? accepted : count + accepted) >= PHONE_WATCH_RULES.minAttemptsPerMinute + }) + if (!judgedMinutes) continue + judged += 1 + const at = recordedTrip(minutes, signal, start, 60, broken) + // Confirms may trip earlier: the stand-in reads as failing confirms on c28 (see above). + const earliest = signal === 'confirmFailures' ? start : start + 30 + assert.ok(at !== null && at >= earliest && at <= start + 32, `${cell} ${name}: from minute ${start + 30} trips at ${at}`) + } + // Busy cells are judged most of the day; low-volume c7 rarely reaches 4 attempts a minute. + if (cell !== 'production-gce-c7') assert.ok(judged > 150, `${cell} ${name}: ${judged} judged`) + } + } +}) diff --git a/cloud/packages/relay-contract/src/close-codes.ts b/cloud/packages/relay-contract/src/close-codes.ts index cb2c6307b906..504bcd11b08a 100644 --- a/cloud/packages/relay-contract/src/close-codes.ts +++ b/cloud/packages/relay-contract/src/close-codes.ts @@ -8,3 +8,12 @@ export const RELAY_CLOSE_CODE = { } as const export type RelayCloseCode = (typeof RELAY_CLOSE_CODE)[keyof typeof RELAY_CLOSE_CODE] + +// Mirror of MOBILE_RELAY_PHONE_CLOSE_REASON in src/shared/mobile-relay-close-codes.ts +// in the Orca app repo half. Close reasons on a phone socket that refine its close +// code; not RELAY_HOST_CLOSE_REASON, which names host control-socket closes. +export const RELAY_PHONE_CLOSE_REASON = { + // A HOST_OFFLINE sent while the host's control looked alive but its data link + // missed hostAttachDeadlineMs; the phone redials the same cell once, quickly. + HOST_ATTACH_TIMEOUT: 'attach-timeout' +} as const diff --git a/cloud/packages/relay-contract/src/contract.test.ts b/cloud/packages/relay-contract/src/contract.test.ts index 391a0877c651..2a1a134ed6ae 100644 --- a/cloud/packages/relay-contract/src/contract.test.ts +++ b/cloud/packages/relay-contract/src/contract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RELAY_CLOSE_CODE } from './close-codes.js' +import { RELAY_CLOSE_CODE, RELAY_PHONE_CLOSE_REASON } from './close-codes.js' import { AuthRefreshSchema, DrainSchema, @@ -52,6 +52,8 @@ describe('relay protocol contract', () => { LIMIT_EXCEEDED: 4429, DRAINING: 4503 }) + // Shipped phones match this exact text; it is a wire value, not copy. + expect(RELAY_PHONE_CLOSE_REASON).toEqual({ HOST_ATTACH_TIMEOUT: 'attach-timeout' }) expect(RELAY_PROTOCOL_LIMITS).toMatchObject({ firstFrameDeadlineMs: 2_000, maxHttpBodyBytes: 4_096, diff --git a/config/dark-build-switches.cjs b/config/dark-build-switches.cjs index 5b5cb5c02838..5ec4448d06c5 100644 --- a/config/dark-build-switches.cjs +++ b/config/dark-build-switches.cjs @@ -2,8 +2,8 @@ const { existsSync } = require('node:fs') const { join } = require('node:path') // Dark build switches: a build compiles a switch's path in only when its variable is 'main'. Such a -// main build leaves the switch's marker in out/main, and packaging refuses it; packaging and the -// orcad template also refuse the variable itself. +// main build leaves the switch's marker in out/main and in the phone page bundle (out/mobile-web), +// and packaging refuses either; packaging and the orcad template also refuse the variable itself. const DARK_BUILD_SWITCHES = [ // src/shared/layout-owner-switch.ts { variable: 'ORCA_LAYOUT_OWNER', off: 'window' }, @@ -37,7 +37,12 @@ function turnedOnDarkBuildDefines(env = process.env) { ) } -/** The marker each switched-on build leaves in out/main. */ +/** Every marker a switched-on build can leave, so a bundle check accounts for them. */ +function darkBuildMarkerFileNames() { + return DARK_BUILD_SWITCHES.map(markerFileName) +} + +/** The marker each switched-on build leaves in its output. */ function darkBuildMarkers(env = process.env) { return turnedOn(env).map((darkSwitch) => ({ fileName: markerFileName(darkSwitch), @@ -45,14 +50,8 @@ function darkBuildMarkers(env = process.env) { })) } -function assertNotDarkBuild(outputDirs, env = process.env) { - const [asked] = turnedOn(env) - if (asked) { - throw new Error( - `${asked.variable}=main is for dark test builds; unset it to package a release.` - ) - } - for (const darkSwitch of DARK_BUILD_SWITCHES) { +function assertUnmarked(outputDirs, switches) { + for (const darkSwitch of switches) { const marked = outputDirs.find((dir) => existsSync(join(dir, markerFileName(darkSwitch)))) if (marked) { throw new Error( @@ -62,9 +61,30 @@ function assertNotDarkBuild(outputDirs, env = process.env) { } } +function assertNotDarkBuild(outputDirs, env = process.env) { + const [asked] = turnedOn(env) + if (asked) { + throw new Error( + `${asked.variable}=main is for dark test builds; unset it to package a release.` + ) + } + assertUnmarked(outputDirs, DARK_BUILD_SWITCHES) +} + +/** For a build that may itself be dark: refuses only outputs marked by a switch this build has off. */ +function assertNoDarkOutputOfOffSwitches(outputDirs, env = process.env) { + const on = turnedOn(env) + assertUnmarked( + outputDirs, + DARK_BUILD_SWITCHES.filter((darkSwitch) => !on.includes(darkSwitch)) + ) +} + module.exports = { + assertNoDarkOutputOfOffSwitches, assertNotDarkBuild, darkBuildDefines, + darkBuildMarkerFileNames, darkBuildMarkers, turnedOnDarkBuildDefines } diff --git a/config/deprecated-runtime-owner-importers-baseline.txt b/config/deprecated-runtime-owner-importers-baseline.txt deleted file mode 100644 index 37adb8baff40..000000000000 --- a/config/deprecated-runtime-owner-importers-baseline.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Renderer files that import getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree. -# This is a RATCHET: the list may only SHRINK. Both resolvers read an ambiguous (two-host) -# worktree as "not a runtime", which callers treat as local. -# Prune after migrating a file: pnpm check:deprecated-runtime-owner-ratchet --prune -src/renderer/src/components/terminal-workspace-model.ts -src/renderer/src/lib/agent-hibernation-coordinator.ts -src/renderer/src/store/slices/terminal-tab-retirement.ts -src/renderer/src/store/terminals/terminal-pane-hibernation.ts -src/renderer/src/store/terminals/terminal-shutdown.ts diff --git a/config/e2e-typecheck-baseline.txt b/config/e2e-typecheck-baseline.txt index c96721068268..e22e466ed325 100644 --- a/config/e2e-typecheck-baseline.txt +++ b/config/e2e-typecheck-baseline.txt @@ -27,8 +27,6 @@ 1 tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts 1 tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts 1 tests/e2e/cross-version-wire/versioned-agent-session-wire.ts -1 tests/e2e/daemon-generation-legacy-close-safety.spec.ts -1 tests/e2e/daemon-generation-reconnect-safety.spec.ts 1 tests/e2e/editable-context-paste-ownership.spec.ts 1 tests/e2e/file-explorer-linked-watch-refresh.spec.ts 5 tests/e2e/fixtures/daemon-generation-legacy-close-client.ts @@ -38,7 +36,6 @@ 1 tests/e2e/github-created-issue-start-prefill.spec.ts 1 tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts 1 tests/e2e/headless-serve-desktop-activation.spec.ts -1 tests/e2e/helpers/daemon-generation-safety-fixtures.ts 5 tests/e2e/helpers/headless-paired-runtime-host.ts 1 tests/e2e/helpers/native-chat-view.ts 2 tests/e2e/helpers/nested-runtime-ssh-client-route.ts @@ -121,8 +118,6 @@ 2 tests/e2e/terminal-tui-wheel-reports.spec.ts 1 tests/e2e/terminal-warm-reattach-split-live-output.spec.ts 1 tests/e2e/workspace-space-git-status.spec.ts -1 tests/e2e/worktree-parent-reorder.spec.ts -1 tests/e2e/worktree-scroll-to-current.spec.ts 1 tests/tools/omp-child-history-rendered/fixture.tsx 1 tests/tools/omp-fresh-launch-windows-smoke.ts 1 tests/tools/omp-remote-sidebar-rendered/fixture.tsx diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 6b2d06d3ba2b..bf246728756a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -403,7 +403,7 @@ module.exports = { mobileWebBundleDir = MOBILE_WEB_BUNDLE_DIR, ensureWaylandClipboard = ensureBundledWaylandClipboard ) => { - assertNotDarkBuild([join(__dirname, '..', 'out', 'main')]) + assertNotDarkBuild([join(__dirname, '..', 'out', 'main'), mobileWebBundleDir]) assertPackagedNativeVariantsInstalled(context.electronPlatformName, context.arch) assertBundledRipgrepInstalled() assertProcessHostOutputBuilt() diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index d75bb384ccc2..8b10bcf2eec2 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -304,7 +304,6 @@ 1 src/renderer/src/lib/agent-background-session-launch-host.ts 1 src/renderer/src/lib/agent-detection-target-inventory.ts 1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts -1 src/renderer/src/lib/agent-hibernation-coordinator.ts 2 src/renderer/src/lib/agent-launch-route-input.ts 1 src/renderer/src/lib/agent-message-send.ts 1 src/renderer/src/lib/ai-vault-resume-platform.ts @@ -346,7 +345,7 @@ 1 src/renderer/src/lib/tab-number-shortcuts.ts 1 src/renderer/src/lib/terminal-close-route.ts 1 src/renderer/src/lib/terminal-tab-owner-activation.ts -5 src/renderer/src/lib/terminal-worktree-route.ts +6 src/renderer/src/lib/terminal-worktree-route.ts # resolveTerminalPtyOwner: teardown, close and PTY identity read spawn's attribution (round 3 F8), which retired the deprecated resolver's readers 3 src/renderer/src/lib/unread-badge-count-selector.ts 2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts 5 src/renderer/src/lib/workspace-browser-tab-open.ts @@ -359,7 +358,7 @@ 1 src/renderer/src/lib/worktree-jump-navigation.ts 2 src/renderer/src/lib/worktree-operation-route.ts 1 src/renderer/src/lib/worktree-row-open-in-owner.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down -9 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver, its null-collapsing wrapper, and the legacy-fallback adapter the folds move to (round 3 F0); deleting the deprecated pair (F8) nets it down +8 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver and the legacy-fallback adapter the folds moved to (round 3 F0); F8 deleted the deprecated pair 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/lib/worktree-sort-order-persistence.ts # its split helper calls resolveOwnerWithLegacyFallback directly (round 3 F4); F8 nets it down 1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -451,7 +450,7 @@ 8 src/renderer/src/store/slices/store-test-helpers.ts 1 src/renderer/src/store/slices/tabs/tabs-close-actions.ts 2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts -2 src/renderer/src/store/slices/terminal-tab-retirement.ts +1 src/renderer/src/store/slices/terminal-tab-retirement.ts 3 src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization-fixture.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts 3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts @@ -468,8 +467,6 @@ 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts 1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts 2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts -1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts -1 src/renderer/src/store/terminals/terminal-shutdown.ts 1 src/renderer/src/store/terminals/terminal-tab-appearance.ts 1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts 1 src/renderer/src/web/preload-api/web-app-api.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 7cb50f9826ac..d7c44c3317b9 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -92,7 +92,6 @@ src/main/plugins/plugin-private-marketplace-ssh-shim.cjs src/main/ports/port-scan-command-execution.ts src/main/providers/macos-login-session-pty-probe.ts src/main/providers/process-cwd.ts -src/main/providers/windows-console-attached-processes.ts src/main/pty-descendant-termination.ts src/main/pty/posix-pty-foreground-group.ts src/main/pty/windows-environment-path.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fd1f9bb8a4b1..1539dd914778 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -9933,7 +9933,8 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty-pane-materialization-race.test.ts src/main/ipc/pty-persisted-incarnation-repair.test.ts src/main/ipc/pty-pane-reservation-settlement.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", - "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/structured-agent-cli-writers.test.ts src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts src/main/providers/agent-foreground-process-git-bash.win32.test.ts" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", @@ -9948,9 +9949,27 @@ "src/renderer/src/lib/pane-manager/pane-fit.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-hidden-replay.test.ts", "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", - "tests/e2e/live-background-terminal-mount-authority.spec.ts" + "tests/e2e/live-background-terminal-mount-authority.spec.ts", + "src/main/runtime/structured-agent-cli-writers.test.ts", + "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts", + "src/main/providers/agent-foreground-process-git-bash.win32.test.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/structured-agent-cli-writers.test.ts", + "assertions": [ + "accepts exact exited or unchanged shell-only proof, never loss of evidence", + "inspects the terminal a conversation leaves, whatever its rows now say" + ] + }, + { + "file": "src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts", + "assertions": ["captures source before routing (unary=%s)"] + }, + { + "file": "src/main/providers/agent-foreground-process-git-bash.win32.test.ts", + "assertions": ["sets aside a detached ssh-agent for adoption, but not a background job"] + }, { "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "assertions": [ diff --git a/config/scripts/check-deprecated-runtime-owner-ratchet.mjs b/config/scripts/check-deprecated-runtime-owner-ratchet.mjs deleted file mode 100644 index a28544037379..000000000000 --- a/config/scripts/check-deprecated-runtime-owner-ratchet.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import process from 'node:process' -import { pathToFileURL } from 'node:url' -import { readTrackedSources, runEntryRatchet } from './ratchet-baseline.mjs' - -// Ratchet on renderer files that import the deprecated runtime-owner resolvers. Both read a -// worktree that two hosts publish as "not a runtime", which callers then treat as local, so a stop, -// sleep or kill can land on the wrong machine. New code resolves the owner with its host and -// ambiguity kept; the importer list may only shrink. - -const BASELINE_PATH = 'config/deprecated-runtime-owner-importers-baseline.txt' -const SCAN_ROOT = 'src/renderer/src' -const DEPRECATED_NAMES = ['getRuntimeEnvironmentIdForWorktree', 'resolveRuntimeOwnerForWorktree'] -// The defining module and its own tests are not importers. -const DEFINING_FILE = 'src/renderer/src/lib/worktree-runtime-owner.ts' - -// Any reference to a deprecated name in a file that loads the defining module, whatever the import -// form (named, aliased, namespace, dynamic, `.then` destructuring, re-export). Simpler than tracking -// bindings, and in production code a mention of these names is a use. -const DEPRECATED_NAME = new RegExp(`\\b(?:${DEPRECATED_NAMES.join('|')})\\b`) -const DEFINING_MODULE_SPECIFIER = /['"`][^'"`]*\/worktree-runtime-owner['"`]/ -// Block and line comments; `://` inside a string (a URL) is not a line comment. -const COMMENT = /\/\*[\s\S]*?\*\/|(? importsDeprecatedRuntimeOwner(source)) - .map(([rel]) => rel) - .sort() -} - -const RATCHET = { - name: 'deprecated-runtime-owner', - baselinePath: BASELINE_PATH, - header: [ - '# Renderer files that import getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree.', - '# This is a RATCHET: the list may only SHRINK. Both resolvers read an ambiguous (two-host)', - '# worktree as "not a runtime", which callers treat as local.', - '# Prune after migrating a file: pnpm check:deprecated-runtime-owner-ratchet --prune' - ], - collect: collectDeprecatedRuntimeOwnerImporters, - fix: [ - 'getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree collapse an owner that two', - 'hosts publish into "local". Resolve the owner with its host kept and refuse on ambiguity', - 'instead (see src/renderer/src/lib/resolve-owner.ts).' - ] -} - -export function main(root = process.cwd(), arg = undefined) { - return runEntryRatchet(RATCHET, root, arg) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(main(process.cwd(), process.argv[2])) -} diff --git a/config/scripts/check-deprecated-runtime-owner-ratchet.test.mjs b/config/scripts/check-deprecated-runtime-owner-ratchet.test.mjs deleted file mode 100644 index 8de50f668909..000000000000 --- a/config/scripts/check-deprecated-runtime-owner-ratchet.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - importsDeprecatedRuntimeOwner, - isScannedPath -} from './check-deprecated-runtime-owner-ratchet.mjs' - -describe('importsDeprecatedRuntimeOwner', () => { - it('finds a named import of either deprecated resolver', () => { - expect( - importsDeprecatedRuntimeOwner( - "import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'\n" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "import {\n foo,\n resolveRuntimeOwnerForWorktree\n} from './worktree-runtime-owner'\n" - ) - ).toBe(true) - }) - - it('finds an aliased import, a re-export and a dynamic import', () => { - expect( - importsDeprecatedRuntimeOwner( - "import { getRuntimeEnvironmentIdForWorktree as envFor } from '@/lib/worktree-runtime-owner'" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "export { resolveRuntimeOwnerForWorktree } from './worktree-runtime-owner'" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "const { getRuntimeEnvironmentIdForWorktree } = await import('@/lib/worktree-runtime-owner')" - ) - ).toBe(true) - }) - - it('finds a use through a namespace import or a module-valued dynamic import', () => { - expect( - importsDeprecatedRuntimeOwner( - "import * as owner from '@/lib/worktree-runtime-owner'\nowner.getRuntimeEnvironmentIdForWorktree(s, id)" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "const mod = await import('@/lib/worktree-runtime-owner')\nmod.resolveRuntimeOwnerForWorktree(s, id)" - ) - ).toBe(true) - }) - - it('finds destructuring in a promise-based dynamic import', () => { - expect( - importsDeprecatedRuntimeOwner( - "import('@/lib/worktree-runtime-owner').then(({ resolveRuntimeOwnerForWorktree }) => resolveRuntimeOwnerForWorktree(s, id))" - ) - ).toBe(true) - }) - - it('reads through comments inside an import list, and ignores a commented-out import', () => { - expect( - importsDeprecatedRuntimeOwner( - "import { /* legacy */ getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "import {\n a, // keep\n resolveRuntimeOwnerForWorktree\n} from './worktree-runtime-owner'" - ) - ).toBe(true) - expect( - importsDeprecatedRuntimeOwner( - "// import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'\n/* owner.resolveRuntimeOwnerForWorktree() */" - ) - ).toBe(false) - }) - - it('ignores other names from the module, and same-named functions from other modules', () => { - expect( - importsDeprecatedRuntimeOwner( - "import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'" - ) - ).toBe(false) - expect( - importsDeprecatedRuntimeOwner( - "import { getRuntimeEnvironmentIdForWorktree } from '@/lib/other-owner'" - ) - ).toBe(false) - }) -}) - -describe('isScannedPath', () => { - it('scans renderer sources only, not tests or the defining module', () => { - expect(isScannedPath('src/renderer/src/store/terminals/terminal-shutdown.ts')).toBe(true) - expect(isScannedPath('src/renderer/src/store/terminals/terminal-shutdown.test.ts')).toBe(false) - expect(isScannedPath('src/renderer/src/store/host-twin.test-fixture.ts')).toBe(false) - expect(isScannedPath('src/renderer/src/lib/worktree-runtime-owner.ts')).toBe(false) - expect(isScannedPath('src/main/runtime/foo.ts')).toBe(false) - }) -}) diff --git a/config/scripts/check-node-runtime-pin.mjs b/config/scripts/check-node-runtime-pin.mjs index 6d5205aa525c..d606fb3ff59c 100644 --- a/config/scripts/check-node-runtime-pin.mjs +++ b/config/scripts/check-node-runtime-pin.mjs @@ -109,6 +109,22 @@ export function findNodeRuntimePinProblems({ `NODE_RUNTIME_PIN.version ${pin.version} is not package.json engines.node major ${engineMajor}` ) } + const runtimeMajor = majorOf(pin.version) + const declaredNodeTypes = + packageJson.devDependencies?.['@types/node'] ?? packageJson.dependencies?.['@types/node'] + if (majorOf(declaredNodeTypes) !== runtimeMajor) { + problems.push( + `package.json @types/node is ${declaredNodeTypes}, but NODE_RUNTIME_PIN.version major is ${runtimeMajor}` + ) + } + const lockedNodeTypes = lockedVersion( + rootImporter.devDependencies?.['@types/node'] ?? rootImporter.dependencies?.['@types/node'] + ) + if (majorOf(lockedNodeTypes) !== runtimeMajor) { + problems.push( + `pnpm-lock.yaml resolves @types/node ${lockedNodeTypes}, but NODE_RUNTIME_PIN.version major is ${runtimeMajor}` + ) + } if (!Number.isInteger(pin.napi) || pin.napi < 1) { problems.push(`NODE_RUNTIME_PIN.napi must be a positive integer, got ${pin.napi}`) } diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index 885bbc397768..121c6628229a 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -45,10 +45,14 @@ function validInput() { pin, assets, targets, - packageJson: { devDependencies: { electron: '43.7.5' }, engines: { node: '24' } }, + packageJson: { + devDependencies: { electron: '43.7.5', '@types/node': '^24.19.2' }, + engines: { node: '24' } + }, rootImporter: { devDependencies: { - electron: { specifier: '43.7.5', version: '43.7.5(supports-color@7.2.0)' } + electron: { specifier: '43.7.5', version: '43.7.5(supports-color@7.2.0)' }, + '@types/node': { specifier: '^24.19.2', version: '24.19.2' } } } } @@ -85,6 +89,35 @@ describe('findNodeRuntimePinProblems', () => { ]) }) + it.each(['^25.6.0', '^22.20.0', undefined])( + 'rejects declared Node types %s outside the runtime major', + (nodeTypes) => { + const input = validInput() + input.packageJson.devDependencies['@types/node'] = nodeTypes + expect(findNodeRuntimePinProblems(input)).toEqual([ + `package.json @types/node is ${nodeTypes}, but NODE_RUNTIME_PIN.version major is 24` + ]) + } + ) + + it.each(['25.6.0', '22.20.0', undefined])( + 'rejects locked Node types %s outside the runtime major', + (nodeTypes) => { + const input = validInput() + input.rootImporter.devDependencies['@types/node'].version = nodeTypes + expect(findNodeRuntimePinProblems(input)).toEqual([ + `pnpm-lock.yaml resolves @types/node ${nodeTypes ?? null}, but NODE_RUNTIME_PIN.version major is 24` + ]) + } + ) + + it('accepts Node type versions within the runtime major', () => { + const input = validInput() + input.packageJson.devDependencies['@types/node'] = '^24.18.0' + input.rootImporter.devDependencies['@types/node'].version = '24.19.0' + expect(findNodeRuntimePinProblems(input)).toEqual([]) + }) + it('requires exactly one asset per server target', () => { const input = validInput() delete input.assets['win32-x64'] diff --git a/config/scripts/check-unproven-pty-retirement-ratchet.mjs b/config/scripts/check-unproven-pty-retirement-ratchet.mjs new file mode 100644 index 000000000000..c4115a47de47 --- /dev/null +++ b/config/scripts/check-unproven-pty-retirement-ratchet.mjs @@ -0,0 +1,66 @@ +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { readTrackedSources, runCountRatchet } from './ratchet-baseline.mjs' + +// Ratchet on PTY retirements that rest on no exit proof (src/main/pty-exit-proof.ts): unproven +// ones, and ownership drops outside `finishPtyShutdown`, which name no evidence at all. Each drops +// the handle a later stop could use while the process may still run. Counts may only go down. + +const BASELINE_PATH = 'config/unproven-pty-retirement-baseline.txt' +const SCAN_ROOTS = ['src'] +const UNPROVEN_RETIREMENT = /\bPtyRetirementWithoutProof\.because\(|\bptyOwnership\.delete\(/g +const COMMENT = /\/\*[\s\S]*?\*\/|(? 0) { + counts.set(rel, count) + } + } + return counts +} + +export function main(root = process.cwd(), arg = undefined) { + return runCountRatchet( + { + name: 'unproven-pty-retirement', + baselinePath: BASELINE_PATH, + header: [ + '# PTY retirements on no exit proof per file: PtyRetirementWithoutProof.because, and raw', + '# ptyOwnership.delete outside finishPtyShutdown. This is a RATCHET: counts may only go DOWN.', + '# Retire through finishPtyShutdown on a PtyExitProof, or record the stop durably first.', + '# Prune after a fix: pnpm check:unproven-pty-retirement-ratchet --prune' + ], + collect: collectUnprovenPtyRetirementCounts, + unit: 'unproven PTY retirement(s)', + fix: [ + 'Retire a PTY through finishPtyShutdown on a PtyExitProof (src/main/pty-exit-proof.ts), or', + 'keep its ownership until one arrives. A close the host cannot answer must read unverifiable.' + ] + }, + root, + arg + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main(process.cwd(), process.argv[2])) +} diff --git a/config/scripts/check-unproven-pty-retirement-ratchet.test.mjs b/config/scripts/check-unproven-pty-retirement-ratchet.test.mjs new file mode 100644 index 000000000000..4ee420f134bf --- /dev/null +++ b/config/scripts/check-unproven-pty-retirement-ratchet.test.mjs @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { + countUnprovenPtyRetirements, + isScannedPath +} from './check-unproven-pty-retirement-ratchet.mjs' + +describe('countUnprovenPtyRetirements', () => { + it('counts each unproven mint and each raw ownership drop', () => { + expect( + countUnprovenPtyRetirements( + 'const a = PtyRetirementWithoutProof.because(R)\nf(PtyRetirementWithoutProof.because("x"))' + ) + ).toBe(2) + expect(countUnprovenPtyRetirements('ptyOwnership.delete(id)\nptyOwnership.get(id)')).toBe(1) + }) + + it('ignores comments, proofs and the type name alone', () => { + expect( + countUnprovenPtyRetirements( + '// PtyRetirementWithoutProof.because(r)\n/* PtyRetirementWithoutProof.because(r) */\n' + + "PtyExitProof.from('absent')\nx instanceof PtyRetirementWithoutProof" + ) + ).toBe(0) + }) +}) + +describe('isScannedPath', () => { + it('scans production TypeScript only', () => { + expect(isScannedPath('src/main/ipc/pty/runtime/kill.ts')).toBe(true) + expect(isScannedPath('src/main/ipc/pty/runtime/kill.test.ts')).toBe(false) + expect(isScannedPath('src/main/runtime/a.spec.ts')).toBe(false) + expect(isScannedPath('src/main/ipc/pty/daemon-late-exit-test-fixture.ts')).toBe(false) + expect(isScannedPath('src/main/ipc/pty/provider/liveness.ts')).toBe(false) + }) +}) diff --git a/config/scripts/check-worktree-id-composer-ratchet.mjs b/config/scripts/check-worktree-id-composer-ratchet.mjs index 14af4a11a37d..e9097abd4a80 100644 --- a/config/scripts/check-worktree-id-composer-ratchet.mjs +++ b/config/scripts/check-worktree-id-composer-ratchet.mjs @@ -1,14 +1,6 @@ -import fs from 'node:fs' -import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' -import { - diffCounts, - parseCountBaseline, - pruneCountBaseline, - readTrackedSources, - totalCount -} from './ratchet-baseline.mjs' +import { readTrackedSources, runCountRatchet } from './ratchet-baseline.mjs' // Ratchet on worktree ids built by hand (`${repo.id}::${path}`) instead of through the composers in // src/shared/worktree/id.ts. About 22 hand-built ids each pick whichever path spelling is at hand, @@ -57,38 +49,20 @@ export function collectHandBuiltWorktreeIdCounts(root = process.cwd()) { } export function main(root = process.cwd(), arg = undefined) { - const current = collectHandBuiltWorktreeIdCounts(root) - if (arg === '--prune') { - const total = pruneCountBaseline(root, BASELINE_PATH, HEADER, current) - console.log(`worktree-id-composer baseline pruned to ${total}.`) - return 0 - } - const baselineFile = path.join(root, BASELINE_PATH) - const baseline = parseCountBaseline( - fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' + return runCountRatchet( + { + name: 'worktree-id-composer', + baselinePath: BASELINE_PATH, + header: HEADER, + collect: collectHandBuiltWorktreeIdCounts, + unit: 'hand-built worktree id(s)', + fix: [ + 'Compose worktree ids with composeWorktreeId / getRepoMainWorktreeId (src/shared/worktree/id.ts).' + ] + }, + root, + arg ) - const { grown, shrunk } = diffCounts(current, baseline) - for (const { file, now, allowed } of grown) { - console.error(`${file}: ${now} hand-built worktree id(s), baseline allows ${allowed}.`) - } - if (grown.length > 0) { - console.error( - 'Compose worktree ids with composeWorktreeId / getRepoMainWorktreeId (src/shared/worktree/id.ts).' - ) - } - for (const { file, now, allowed } of shrunk) { - console.error(`${file}: now ${now}, baseline ${allowed}.`) - } - if (shrunk.length > 0) { - console.error( - 'The baseline may only shrink. Fix it: pnpm check:worktree-id-composer-ratchet --prune' - ) - } - if (grown.length + shrunk.length > 0) { - return 1 - } - console.log(`worktree-id-composer ratchet OK โ€” ${totalCount(current)} grandfathered id(s).`) - return 0 } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/config/scripts/dark-build-switches.test.mjs b/config/scripts/dark-build-switches.test.mjs index 3b435aa4a6af..f9ff85af1979 100644 --- a/config/scripts/dark-build-switches.test.mjs +++ b/config/scripts/dark-build-switches.test.mjs @@ -4,8 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -const { assertNotDarkBuild, darkBuildDefines, darkBuildMarkers, turnedOnDarkBuildDefines } = - createRequire(import.meta.url)('../dark-build-switches.cjs') +const { + assertNoDarkOutputOfOffSwitches, + assertNotDarkBuild, + darkBuildDefines, + darkBuildMarkers, + turnedOnDarkBuildDefines +} = createRequire(import.meta.url)('../dark-build-switches.cjs') const scratch = [] afterEach(async () => { @@ -48,4 +53,18 @@ describe('dark build switches', () => { expect(() => assertNotDarkBuild([clean, dark], {})).toThrow(dark) } ) + + it.each([ + ['ORCA_LAYOUT_OWNER', 'ORCA_SIDEBAR_INPUTS'], + ['ORCA_SIDEBAR_INPUTS', 'ORCA_LAYOUT_OWNER'] + ])('lets a dark build carry its own %s output, never one of %s', async (own, other) => { + const dark = await mkdtemp(join(tmpdir(), 'orca-dark-build-own-')) + scratch.push(dark) + const [marker] = darkBuildMarkers({ [own]: 'main' }) + await writeFile(join(dark, marker.fileName), marker.text) + + expect(() => assertNoDarkOutputOfOffSwitches([dark], { [own]: 'main' })).not.toThrow() + expect(() => assertNoDarkOutputOfOffSwitches([dark], { [other]: 'main' })).toThrow(dark) + expect(() => assertNoDarkOutputOfOffSwitches([dark], {})).toThrow(dark) + }) }) diff --git a/config/scripts/layout-save-divergence-summary.mjs b/config/scripts/layout-save-divergence-summary.mjs new file mode 100644 index 000000000000..ea5e2519b3bd --- /dev/null +++ b/config/scripts/layout-save-divergence-summary.mjs @@ -0,0 +1,148 @@ +// Counts a layout-owner run's save-divergence reports (ORCA_LAYOUT_SAVE_DIVERGENCE_LOG) by kind +// and by the spec that was running when each was written. +// Usage: node layout-save-divergence-summary.mjs [playwright.json...] +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { basename } from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g +// Saves land a little after the action that caused them, sometimes after the test ends. +const AFTER_END_MS = 15_000 +const BEFORE_START_MS = 2_000 + +/** A report path with its record id (a uuid or a file path) dropped: `group changed: tabOrder`. */ +export function divergenceKind(path) { + const named = + /^(editor view|pane tree|group|tab|file|browser|leaf|terminal) (.+?) (added|removed|changed: .*|tab-bar entry removed|kept over .*)$/s.exec( + path + ) + const kind = named ? `${named[1]} ${named[3]}` : path.replace(/^\d+ /, ' ') + return kind.replace(UUID, '') +} + +/** Every test attempt's time span, from Playwright JSON reports. */ +export function testSpans(reports) { + const spans = [] + const visit = (suite, file) => { + const suiteFile = suite.file ?? file + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const result of test.results ?? []) { + const start = Date.parse(result.startTime) + if (Number.isFinite(start)) { + spans.push({ + // The title too: a loop declares many tests on one line. + spec: `${spec.file ?? suiteFile}:${spec.line} ${spec.title}`, + start, + end: start + (result.duration ?? 0) + }) + } + } + } + } + for (const child of suite.suites ?? []) { + visit(child, suiteFile) + } + } + for (const report of reports) { + for (const suite of report.suites ?? []) { + visit(suite) + } + } + return spans.sort((left, right) => left.start - right.start) +} + +function specAt(at, spans) { + const inside = spans.findLast((span) => span.start <= at && at <= span.end) + if (inside) { + return inside.spec + } + const near = spans.findLast((span) => span.end < at && at - span.end <= AFTER_END_MS) + const next = spans.find((span) => span.start > at && span.start - at <= BEFORE_START_MS) + return (near ?? next)?.spec ?? 'unattributed' +} + +const increment = (counts, key) => { + counts[key] = (counts[key] ?? 0) + 1 +} + +export function summarizeDivergence(lines, reports) { + const spans = testSpans(reports) + const summary = { + reports: 0, + paths: 0, + unreadable: 0, + byKind: {}, + bySpec: {}, + bySpecAndKind: {} + } + for (const line of lines) { + if (!line.trim()) { + continue + } + let report + try { + report = JSON.parse(line) + } catch { + // A line cut short by a killed app. + summary.unreadable++ + continue + } + const spec = specAt(report.at, spans) + summary.reports++ + increment(summary.bySpec, spec) + for (const path of report.paths ?? []) { + const kind = divergenceKind(path) + summary.paths++ + increment(summary.byKind, kind) + summary.bySpecAndKind[spec] ??= {} + increment(summary.bySpecAndKind[spec], kind) + } + } + return summary +} + +export function divergenceMarkdown(summary, title) { + const rows = (counts) => + Object.entries(counts) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([key, count]) => `| ${count} | \`${key.replaceAll('|', '\\|')}\` |`) + const lines = [ + `### ${title}`, + '', + `${summary.reports} reports, ${summary.paths} differences, ${summary.unreadable} unreadable lines.`, + '' + ] + if (summary.reports === 0) { + return `${lines.join('\n')}\n` + } + lines.push('| Count | Kind |', '| ---: | --- |', ...rows(summary.byKind), '') + for (const [spec, kinds] of Object.entries(summary.bySpecAndKind).sort()) { + lines.push(`
${spec}: ${summary.bySpec[spec]} reports`, '') + lines.push('| Count | Kind |', '| ---: | --- |', ...rows(kinds), '', '
', '') + } + return `${lines.join('\n')}\n` +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [logPath, summaryPath, ...reportPaths] = process.argv.slice(2) + const lines = existsSync(logPath) ? readFileSync(logPath, 'utf8').split('\n') : [] + const reports = reportPaths + .filter((path) => existsSync(path)) + .flatMap((path) => { + try { + return [JSON.parse(readFileSync(path, 'utf8'))] + } catch { + // A report cut short by a killed run: its divergences count as unattributed. + return [] + } + }) + const summary = summarizeDivergence(lines, reports) + writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`) + const markdown = divergenceMarkdown(summary, `Layout save divergence: ${basename(logPath)}`) + process.stdout.write(markdown) + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown) + } +} diff --git a/config/scripts/mobile-web-bundle-manifest.mjs b/config/scripts/mobile-web-bundle-manifest.mjs index 2e9888f69c79..3951d0fc1b6c 100644 --- a/config/scripts/mobile-web-bundle-manifest.mjs +++ b/config/scripts/mobile-web-bundle-manifest.mjs @@ -1,8 +1,11 @@ import { createHash } from 'node:crypto' import { readFile, mkdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +const { darkBuildMarkers } = createRequire(import.meta.url)('../dark-build-switches.cjs') + const projectDir = fileURLToPath(new URL('../..', import.meta.url)) export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1 @@ -118,7 +121,8 @@ export async function writeMobileWebBundleTree({ protocolWindow, // Empty for a tree with no route list at all: a shell reading it finds no screen listed and // renders every route natively. - routes = [] + routes = [], + env = process.env }) { const assets = written .map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType })) @@ -143,5 +147,9 @@ export async function writeMobileWebBundleTree({ await writeFile(join(outDir, asset.path), asset.bytes) } await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + // Outside the manifest, so the page's bytes and buildId stay what the switch compiled. + for (const marker of darkBuildMarkers(env)) { + await writeFile(join(outDir, marker.fileName), marker.text, 'utf8') + } return { manifest, outDir } } diff --git a/config/scripts/mobile-web-bundle-manifest.test.mjs b/config/scripts/mobile-web-bundle-manifest.test.mjs index 81c447878a8a..b6bf8f7e65d6 100644 --- a/config/scripts/mobile-web-bundle-manifest.test.mjs +++ b/config/scripts/mobile-web-bundle-manifest.test.mjs @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -87,4 +88,34 @@ describe('writeMobileWebBundleTree', () => { await rm(outDir, { recursive: true, force: true }) } }) + + it('marks a dark build outside the manifest, so its buildId is the same treeโ€™s', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'mobile-web-bundle-manifest-')) + try { + const bytes = Buffer.from('\n', 'utf8') + const tree = (env) => + writeMobileWebBundleTree({ + outDir, + written: [ + { + path: 'index.html', + bytes, + sha256: sha256Hex(bytes), + byteLength: bytes.byteLength, + contentType: contentTypeForExtension('html') + } + ], + desktopVersion: '1.0.0', + protocolWindow: { runtimeProtocolVersion: 3, minCompatibleRuntimeProtocolVersion: 3 }, + env + }) + const dark = await tree({ ORCA_SIDEBAR_INPUTS: 'main' }) + expect(existsSync(join(outDir, 'ORCA_SIDEBAR_INPUTS_MAIN'))).toBe(true) + const off = await tree({}) + expect(existsSync(join(outDir, 'ORCA_SIDEBAR_INPUTS_MAIN'))).toBe(false) + expect(dark.manifest.buildId).toBe(off.manifest.buildId) + } finally { + await rm(outDir, { recursive: true, force: true }) + } + }) }) diff --git a/config/scripts/orcad-mobile-web-bundle.mjs b/config/scripts/orcad-mobile-web-bundle.mjs index 2aad10ad967c..27fea82fcf01 100644 --- a/config/scripts/orcad-mobile-web-bundle.mjs +++ b/config/scripts/orcad-mobile-web-bundle.mjs @@ -10,14 +10,19 @@ import { const { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt } = createRequire(import.meta.url)( './verify-packaged-mobile-web-bundle.cjs' ) +const { assertNoDarkOutputOfOffSwitches } = createRequire(import.meta.url)( + '../dark-build-switches.cjs' +) /** * The built page (pnpm build:mobile-web) into an orcad package. Fatal when it is missing or stale, * as electron-builder's beforePack is: a server without it serves phones no page. */ export function stageOrcadMobileWebBundle(outputDir, bundleDir = MOBILE_WEB_BUNDLE_DIR) { + // The release template refuses the variable itself. + assertNoDarkOutputOfOffSwitches([bundleDir]) assertMobileWebBundleBuilt(bundleDir) - // The guard refuses unlisted files, so the tree is exactly the manifest and its assets. + // The guard refuses unlisted files, so the tree is the manifest, its assets and a dark marker. cpSync(bundleDir, join(outputDir, ...ORCAD_MOBILE_WEB_DIR.split('/')), { recursive: true }) } diff --git a/config/scripts/orcad-mobile-web-bundle.test.mjs b/config/scripts/orcad-mobile-web-bundle.test.mjs index 27079a8c0ef9..44a5c417013a 100644 --- a/config/scripts/orcad-mobile-web-bundle.test.mjs +++ b/config/scripts/orcad-mobile-web-bundle.test.mjs @@ -23,6 +23,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks() + vi.unstubAllEnvs() rmSync(scratch, { recursive: true, force: true }) }) @@ -42,6 +43,18 @@ it('stages the manifest and every asset it lists, byte for byte', async () => { ) }) +it('stages a dark page into a dark orcad, and refuses it in any other', async () => { + vi.stubEnv('ORCA_SIDEBAR_INPUTS', 'main') + await writeMobileWebBundleFixtureTree({ outDir: bundleDir }) + stageOrcadMobileWebBundle(outputDir, bundleDir) + expect(existsSync(join(outputDir, ORCAD_MOBILE_WEB_MANIFEST))).toBe(true) + + vi.unstubAllEnvs() + rmSync(outputDir, { recursive: true, force: true }) + expect(() => stageOrcadMobileWebBundle(outputDir, bundleDir)).toThrow('ORCA_SIDEBAR_INPUTS=main') + expect(existsSync(join(outputDir, ORCAD_MOBILE_WEB_MANIFEST))).toBe(false) +}) + it('fails the build when the page was never built', () => { expect(() => stageOrcadMobileWebBundle(outputDir, bundleDir)).toThrow('pnpm build:mobile-web') expect(existsSync(join(outputDir, ORCAD_MOBILE_WEB_MANIFEST))).toBe(false) diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index ff69c2c67c11..409c241f7ab1 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -312,6 +312,7 @@ function createMobileCiScopeRepository( 'config/scripts/pr-code-change-scope.mjs', 'config/scripts/pr-package-test-paths.mjs', 'config/scripts/pr-layout-oracle-route.mjs', + 'config/scripts/pr-layout-owner-switch-on-route.mjs', 'config/scripts/git-pull-request-diff-base.mjs', 'config/scripts/pr-e2e-source-routing.mjs', 'config/scripts/ci-e2e-job-selection.mjs' diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 65fdeb85af9d..f6ef58c1f8d6 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -269,6 +269,18 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + // The spec runs only in a switch-on build; it guards the phone's list against the desktop's. + id: 'ssh.orcad-phone-feed-list', + specs: ['tests/e2e/ssh-orcad-phone-feed-list.spec.ts'], + matches: (file) => + MANAGED_SSH_HARNESS.test(file) || + /^tests\/e2e\/helpers\/(?:phone-feed-list|sidebar-inputs-build)\.ts$/.test(file) || + (isProductSource(file) && + /^(?:src\/shared\/sidebar-list\/sidebar-(?:feed|inputs)-|mobile\/src\/host-screen\/host-sidebar-feed-)/.test( + file + )) + }, { id: 'browser-network.ssh-docker-route', specs: ['tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts'], diff --git a/config/scripts/pr-layout-owner-switch-on-route.mjs b/config/scripts/pr-layout-owner-switch-on-route.mjs new file mode 100644 index 000000000000..a2c70580bf8f --- /dev/null +++ b/config/scripts/pr-layout-owner-switch-on-route.mjs @@ -0,0 +1,65 @@ +// Paths whose change runs the advisory `layout_owner_switch_on` job in pr.yml. +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { MANAGED_SSH_E2E_SPEC_PREFIX } from './ci-e2e-job-selection.mjs' +import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' + +/** The oracle, its positive control, and the layout-owner acceptance specs. */ +export const LAYOUT_OWNER_SWITCH_ON_E2E_SPECS = [ + ...LAYOUT_ORACLE_E2E_SPECS, + // Positive control: its direct store write must leave at least one divergence report. + 'tests/e2e/cli-file-open-position.spec.ts', + 'tests/e2e/relaunch-first-terminal.spec.ts', + 'tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts', + 'tests/e2e/terminal-cold-activation-deferral.spec.ts', + 'tests/e2e/cli-first-worktree-single-terminal.spec.ts', + 'tests/e2e/unopened-workspace-agent-single-resume.spec.ts', + 'tests/e2e/terminal-pane-close-layout-consistency.spec.ts', + // Saves that drop a terminal's tab-bar entry. Not diff-word-wrap.spec.ts: its file-diff case + // fails with the switch off too (main's scheduled e2e run 39148564778, shard 2). + 'tests/e2e/golden-file-open-edit-save.spec.ts', + 'tests/e2e/golden-source-control-open-diff.spec.ts', + 'tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts', + // Saves whose tab order differs from main's while a frame is held. + 'tests/e2e/floating-workspace-pane-height.spec.ts', + 'tests/e2e/editor-model-host-isolation.spec.ts', + 'tests/e2e/editor-tab-selection-restore.spec.ts', + 'tests/e2e/finished-agent-ghost-resume.spec.ts', + 'tests/e2e/live-background-terminal-mount-authority.spec.ts', + 'tests/e2e/workspace-back-forward-navigation.spec.ts', + 'tests/e2e/worktree-lineage-toggle-shortcut.spec.ts', + 'tests/e2e/local-cli-terminal-retention.spec.ts', + 'tests/e2e/default-tabs-mark-crash-relaunch.spec.ts', + 'tests/e2e/terminal-cli-split-closed-before-mount.spec.ts', + 'tests/e2e/layout-stream-loss-reopen.spec.ts', + // Every managed-SSH spec, in e2e.yml's Docker lane; its orcad template stays switch-off. + MANAGED_SSH_E2E_SPEC_PREFIX +] + +const LAYOUT_OWNER_SWITCH_ON_PREFIXES = [ + 'src/shared/workspace-layout', + 'src/shared/layout-owner-switch.ts', + 'src/main/persistence/', + 'src/main/runtime/', + 'src/renderer/src/store/', + 'tests/e2e/', + // The job itself. + '.github/workflows/pr.yml', + '.github/workflows/e2e.yml', + 'config/scripts/pr-layout-owner-switch-on-route.mjs', + 'config/scripts/layout-save-divergence-summary.mjs' +] + +export function isLayoutOwnerSwitchOnSource(file) { + return LAYOUT_OWNER_SWITCH_ON_PREFIXES.some((prefix) => file.startsWith(prefix)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let input = '' + process.stdin.setEncoding('utf8') + for await (const chunk of process.stdin) { + input += chunk + } + const changedPaths = input.split(/\r?\n/).filter(Boolean) + process.stdout.write(`${changedPaths.some(isLayoutOwnerSwitchOnSource)}\n`) +} diff --git a/config/scripts/pr-layout-owner-switch-on-route.test.mjs b/config/scripts/pr-layout-owner-switch-on-route.test.mjs new file mode 100644 index 000000000000..9795ec3da027 --- /dev/null +++ b/config/scripts/pr-layout-owner-switch-on-route.test.mjs @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parse } from 'yaml' +import { MANAGED_SSH_E2E_SPEC_PREFIX, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' +import { divergenceKind, summarizeDivergence } from './layout-save-divergence-summary.mjs' +import { + isLayoutOwnerSwitchOnSource, + LAYOUT_OWNER_SWITCH_ON_E2E_SPECS +} from './pr-layout-owner-switch-on-route.mjs' + +const projectDir = resolve(import.meta.dirname, '../..') +const read = (file) => parse(readFileSync(join(projectDir, file), 'utf8')) +const prWorkflow = read('.github/workflows/pr.yml') +const e2eWorkflow = read('.github/workflows/e2e.yml') + +describe('layout owner switch-on job', () => { + const job = prWorkflow.jobs.layout_owner_switch_on + + it('runs the listed specs on a switch-on build of the merge commit', () => { + expect(job.uses).toBe('./.github/workflows/e2e.yml') + expect(job.with.ref).toBe('${{ github.sha }}') + expect(job.with.layout_owner).toBe('main') + expect(JSON.parse(job.with.test_files)).toEqual(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS) + for (const spec of LAYOUT_OWNER_SWITCH_ON_E2E_SPECS) { + if (spec !== MANAGED_SSH_E2E_SPEC_PREFIX) { + expect(existsSync(join(projectDir, spec)), spec).toBe(true) + } + } + // Every spec that gates on the switch-on build marker runs here. + const gated = readdirSync(join(projectDir, 'tests/e2e')) + .filter((file) => file.endsWith('.spec.ts')) + .map((file) => `tests/e2e/${file}`) + .filter((spec) => + readFileSync(join(projectDir, spec), 'utf8').includes('ORCA_LAYOUT_OWNER_MAIN') + ) + expect(gated.length).toBeGreaterThan(0) + expect(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS).toEqual(expect.arrayContaining(gated)) + expect(selectGeneralE2eSpecs(LAYOUT_OWNER_SWITCH_ON_E2E_SPECS)).not.toContain( + 'tests/e2e/workspace-layout-oracle-headless.spec.ts' + ) + }) + + it('does not block the merge and keeps its artifacts apart', () => { + expect(prWorkflow.jobs.verify.needs).not.toContain('layout_owner_switch_on') + const suffixes = Object.values(prWorkflow.jobs) + .filter((other) => other.uses === './.github/workflows/e2e.yml') + .map((other) => other.with.artifact_suffix ?? '') + expect(new Set(suffixes).size).toBe(suffixes.length) + }) + + it('sets the switch only on the app builds, never job-wide', () => { + for (const other of Object.values(e2eWorkflow.jobs)) { + expect(JSON.stringify(other.env ?? {})).not.toContain('layout_owner') + } + const build = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs') + expect(build.run).toContain('export ORCA_LAYOUT_OWNER="$LAYOUT_OWNER"') + expect(e2eWorkflow.on.workflow_call.inputs.layout_owner.default).toBe('') + }) + + it('routes layout sources, the e2e tree and the job itself', () => { + for (const file of [ + 'src/shared/workspace-layout/workspace-layout-load.ts', + 'src/shared/layout-owner-switch.ts', + 'src/main/persistence/window-save-divergence.ts', + 'src/main/runtime/terminal-surface-close.ts', + 'src/renderer/src/store/slices/terminals.ts', + 'tests/e2e/helpers/orca-app.ts', + '.github/workflows/pr.yml', + '.github/workflows/e2e.yml' + ]) { + expect(isLayoutOwnerSwitchOnSource(file), file).toBe(true) + } + for (const file of ['src/renderer/src/components/ui/button.tsx', 'docs/STYLEGUIDE.md']) { + expect(isLayoutOwnerSwitchOnSource(file), file).toBe(false) + } + const filter = prWorkflow.jobs.code_paths.steps.find((step) => step.id === 'e2e_filter') + expect(filter.run).toContain('node config/scripts/pr-layout-owner-switch-on-route.mjs') + expect(prWorkflow.jobs.code_paths.steps[0].with['sparse-checkout']).toContain( + '/config/scripts/pr-layout-owner-switch-on-route.mjs' + ) + }) +}) + +describe('layout save divergence summary', () => { + it('counts reports by kind and by the spec running when each was written', () => { + const id = 'c47bcbc2-ec0c-458b-9d54-a2252e8f747a' + const report = { + suites: [ + { + file: 'a.spec.ts', + specs: [ + { + line: 8, + title: 'opens', + tests: [{ results: [{ startTime: '2026-10-19T00:00:00.000Z', duration: 1000 }] }] + } + ] + } + ] + } + const start = Date.parse('2026-10-19T00:00:00.000Z') + const lines = [ + JSON.stringify({ + at: start + 500, + paths: [`group ${id} changed: tabOrder`, 'workspace added'] + }), + // Written after the test ended, as delayed saves are. + JSON.stringify({ at: start + 5000, paths: [`terminal ${id} tab-bar entry removed`] }), + JSON.stringify({ at: start + 60_000, paths: ['1 pane(s) rebound'] }), + '{"at":', + '' + ] + expect(summarizeDivergence(lines, [report])).toEqual({ + reports: 3, + paths: 4, + unreadable: 1, + byKind: { + 'group changed: tabOrder': 1, + 'workspace added': 1, + 'terminal tab-bar entry removed': 1, + ' pane(s) rebound': 1 + }, + bySpec: { 'a.spec.ts:8 opens': 2, unattributed: 1 }, + bySpecAndKind: { + 'a.spec.ts:8 opens': { + 'group changed: tabOrder': 1, + 'workspace added': 1, + 'terminal tab-bar entry removed': 1 + }, + unattributed: { ' pane(s) rebound': 1 } + } + }) + expect(divergenceKind('tab picker-agent-tab changed: createdAt, terminal')).toBe( + 'tab changed: createdAt, terminal' + ) + expect(divergenceKind('editor view /tmp/a b/c.ts changed: viewState')).toBe( + 'editor view changed: viewState' + ) + expect(divergenceKind('file /tmp/a b/c.ts added')).toBe('file added') + }) +}) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index dad604c88b6a..30196f8c0f2c 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -243,11 +243,11 @@ it('pins every foreground and background step to its selected phase', () => { ['Enforce runtime Electron-import ratchet', staticPhase], ['Enforce focus-read ratchet', staticPhase], ['Enforce layout-writer ratchets', staticPhase], - ['Enforce deprecated runtime-owner importer ratchet', staticPhase], ['Enforce path-literal ratchet', staticPhase], ['Enforce worktree-id composer ratchet', staticPhase], ['Enforce e2e typecheck ratchet', staticPhase], ['Enforce legacy relay fence ratchet', staticPhase], + ['Enforce unproven PTY retirement ratchet', staticPhase], ['Check Node runtime pin', staticPhase], ['Decide whether the orcad terminal smoke runs', staticPhase], ['Verify the generated RPC params catalog', staticPhase], diff --git a/config/scripts/ratchet-baseline.mjs b/config/scripts/ratchet-baseline.mjs index 5acad73836df..6458083e3289 100644 --- a/config/scripts/ratchet-baseline.mjs +++ b/config/scripts/ratchet-baseline.mjs @@ -172,3 +172,40 @@ export function pruneCountBaseline(root, baselinePath, header, current) { fs.writeFileSync(file, formatCountBaseline(pruned, header, parseCountNotes(text))) return totalCount(pruned) } + +/** + * Gate for a count ratchet `{ name, baselinePath, header, collect(root), fix, unit }`: a file above + * its baseline fails with `fix`, one below it fails until pruned. `--prune` lowers rows only. + */ +export function runCountRatchet(ratchet, root, arg) { + const current = ratchet.collect(root) + if (arg === '--prune') { + const total = pruneCountBaseline(root, ratchet.baselinePath, ratchet.header, current) + console.log(`${ratchet.name} baseline pruned to ${total}.`) + return 0 + } + const baselineFile = path.join(root, ratchet.baselinePath) + const baseline = parseCountBaseline( + fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : '' + ) + const { grown, shrunk } = diffCounts(current, baseline) + for (const { file, now, allowed } of grown) { + console.error(`${file}: ${now} ${ratchet.unit}, baseline allows ${allowed}.`) + } + if (grown.length > 0) { + console.error(ratchet.fix.join('\n')) + } + for (const { file, now, allowed } of shrunk) { + console.error(`${file}: now ${now}, baseline ${allowed}.`) + } + if (shrunk.length > 0) { + console.error( + `The baseline may only shrink. Fix it: pnpm check:${ratchet.name}-ratchet --prune` + ) + } + if (grown.length + shrunk.length > 0) { + return 1 + } + console.log(`${ratchet.name} ratchet OK โ€” ${totalCount(current)} grandfathered ${ratchet.unit}.`) + return 0 +} diff --git a/config/scripts/ratchet-baseline.test.mjs b/config/scripts/ratchet-baseline.test.mjs index 170034f05109..922c4e51968f 100644 --- a/config/scripts/ratchet-baseline.test.mjs +++ b/config/scripts/ratchet-baseline.test.mjs @@ -10,6 +10,7 @@ import { parseCountBaseline, parseCountNotes, parseEntryBaseline, + runCountRatchet, runEntryRatchet } from './ratchet-baseline.mjs' @@ -67,6 +68,68 @@ describe('runEntryRatchet', () => { }) }) +describe('runCountRatchet', () => { + let root + afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + function ratchet(current) { + root = mkdtempSync(join(tmpdir(), 'orca-count-ratchet-')) + writeFileSync(join(root, 'baseline.txt'), '# header\n2 a.ts # why\n1 b.ts\n') + return { + name: 'demo', + baselinePath: 'baseline.txt', + header: ['# header'], + collect: () => new Map(current), + unit: 'thing(s)', + fix: ['Fix the file.'] + } + } + + it('fails growth and an unpruned shrink, and passes the baseline as-is', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + expect( + runCountRatchet( + ratchet([ + ['a.ts', 3], + ['b.ts', 1] + ]), + root + ) + ).toBe(1) + expect(error.mock.calls.join('\n')).toContain('a.ts: 3 thing(s), baseline allows 2.') + expect(runCountRatchet(ratchet([['a.ts', 2]]), root)).toBe(1) + expect(error.mock.calls.join('\n')).toContain('b.ts: now 0, baseline 1.') + expect( + runCountRatchet( + ratchet([ + ['b.ts', 1], + ['a.ts', 2] + ]), + root + ) + ).toBe(0) + }) + + it('prunes rows down only, keeping their notes', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + expect( + runCountRatchet( + ratchet([ + ['a.ts', 1], + ['c.ts', 4] + ]), + root, + '--prune' + ) + ).toBe(0) + expect(readFileSync(join(root, 'baseline.txt'), 'utf8')).toBe('# header\n1 a.ts # why\n') + }) +}) + describe('count baseline', () => { it('round-trips through format and parse, dropping zero rows', () => { const counts = new Map([ diff --git a/config/scripts/verify-packaged-mobile-web-bundle.cjs b/config/scripts/verify-packaged-mobile-web-bundle.cjs index b28662a48d58..c630e642bf1d 100644 --- a/config/scripts/verify-packaged-mobile-web-bundle.cjs +++ b/config/scripts/verify-packaged-mobile-web-bundle.cjs @@ -2,6 +2,8 @@ const { createHash } = require('node:crypto') const { readFileSync, readdirSync, statSync } = require('node:fs') const { join, resolve } = require('node:path') +const { darkBuildMarkerFileNames } = require('../dark-build-switches.cjs') + const projectDir = resolve(__dirname, '..', '..') const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web') const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).' @@ -141,7 +143,12 @@ function listBundleFiles(directory, prefix = '') { * the installer; content-addressed names mean stale copies never get overwritten. */ function assertNoUnlistedFiles(bundleDir, manifest) { - const listed = new Set(['manifest.json', ...manifest.assets.map((asset) => asset.path)]) + // A dark build's marker is accounted for here; packaging refuses it through assertNotDarkBuild. + const listed = new Set([ + 'manifest.json', + ...darkBuildMarkerFileNames(), + ...manifest.assets.map((asset) => asset.path) + ]) const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path)) if (strays.length > 0) { throw failure( diff --git a/config/scripts/verify-packaged-mobile-web-bundle.test.mjs b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs index 54f99afd324f..b2f32c58aa73 100644 --- a/config/scripts/verify-packaged-mobile-web-bundle.test.mjs +++ b/config/scripts/verify-packaged-mobile-web-bundle.test.mjs @@ -8,6 +8,7 @@ import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tre const require = createRequire(import.meta.url) const { assertMobileWebBundleBuilt } = require('./verify-packaged-mobile-web-bundle.cjs') const electronBuilderConfig = require('../electron-builder.config.cjs') +const { darkBuildMarkers } = require('../dark-build-switches.cjs') async function withBundle(run) { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) @@ -166,6 +167,20 @@ describe('electron-builder packaging wiring', () => { expect(excludesBuiltBundle).toBe(false) }) + it('verifies a dark page build, and refuses to package it', async () => { + await withBundle(async ({ bundleDir }) => { + const [marker] = darkBuildMarkers({ ORCA_SIDEBAR_INPUTS: 'main' }) + await writeFile(join(bundleDir, marker.fileName), marker.text, 'utf8') + expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow() + expect(() => + electronBuilderConfig.beforePack( + { electronPlatformName: process.platform, arch: process.arch === 'arm64' ? 3 : 1 }, + bundleDir + ) + ).toThrow(`${bundleDir} holds an ORCA_SIDEBAR_INPUTS=main build`) + }) + }) + it('verifies the bundle root it is given, not the repo one', async () => { // The seam exists so unit tests need no built out/; it would be worthless if the root were // accepted and then ignored. diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index ef7364c1a089..0293f9a4f4b1 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -2,6 +2,7 @@ import { SQLITE_RUNTIME_INCLUDE } from './vitest-sqlite-runtime-files.mjs' // SQLite publication, native bindings, worker IPC, socket liveness, and V8 retention use Node. export const NODE_RUNTIME_INCLUDE = [ + 'src/main/runtime/structured-agent-cli-writers.test.ts', 'config/scripts/vitest-sqlite-runtime-boundary.test.ts', 'config/scripts/cloud-security-history-workflow.test.mjs', 'config/scripts/skill-recipe-shell.test.mjs', @@ -18,6 +19,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/ai-vault/session-parse-cache-large-scan.test.ts', 'src/main/ai-vault/session-parse-cache-metadata-checkpoint.test.ts', 'src/main/ai-vault/session-parse-cache-persistence.test.ts', + 'src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts', + 'src/main/ai-vault/session-scanner-scope-walk.test.ts', // Electron main bridges streams with Node's implementation. 'src/main/host/electron-http-client.test.ts', 'src/main/daemon/daemon-init-provider-installation.test.ts', diff --git a/config/unproven-pty-retirement-baseline.txt b/config/unproven-pty-retirement-baseline.txt new file mode 100644 index 000000000000..657349c427c3 --- /dev/null +++ b/config/unproven-pty-retirement-baseline.txt @@ -0,0 +1,14 @@ +# PTY retirements on no exit proof per file: PtyRetirementWithoutProof.because, and raw +# ptyOwnership.delete outside finishPtyShutdown. This is a RATCHET: counts may only go DOWN. +# Retire through finishPtyShutdown on a PtyExitProof, or record the stop durably first. +# Prune after a fix: pnpm check:unproven-pty-retirement-ratchet --prune + +1 src/main/ipc/pty/ipc/renderer-kill.ts # SSH provider unregistered: R4-4 tab-close replay +1 src/main/ipc/pty/pane/absent-pane-owner.ts # host-confirmed absence, untyped +1 src/main/ipc/pty/pane/spawn-registration.ts # drops ownership even when the discard shutdown threw +1 src/main/ipc/pty/provider/bind-listeners.ts # observed provider exit, untyped +1 src/main/ipc/pty/provider/local-configure.ts # observed local exit, untyped +1 src/main/ipc/pty/provider/ownership-state.ts # deletePtyOwnership, test-only caller +1 src/main/ipc/pty/provider/state-cleanup.ts # clearPtyOwnershipForConnection, test-only caller +3 src/main/ipc/pty/runtime/kill.ts # 2x SSH provider unregistered (R4-4); unconfirmed follow-up stop of a rejected spawn +1 src/main/ssh/legacy-relay/legacy-relay-pane-stops.ts # COMPAT(legacy-relay-bridge): deleted with the folder diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index e236389c7d87..9b9a6f5a62f5 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -156,6 +156,8 @@ Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `ic Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive โ€” exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper. +Use `DialogContent surface="security"` for every dialog that asks the user to trust, approve, run or remove code, including plugin provenance and approval, so plugin themes cannot change the trust surface's tokens. Use its `scrollable` prop when the whole dialog scrolls. Wrap a trust decision outside a dialog, such as an agent approval card or a certificate override, in `SecuritySurface`. + ### Picking the right primitive When a control has multiple plausible primitives, use this fork: diff --git a/docs/reference/agent-browser-focus-isolation.md b/docs/reference/agent-browser-focus-isolation.md index 4a9ba912241f..07299d98e429 100644 --- a/docs/reference/agent-browser-focus-isolation.md +++ b/docs/reference/agent-browser-focus-isolation.md @@ -75,17 +75,23 @@ popup and copies every frame. It stays an experiment, not the fix. If the pane does not answer, the input is refused with `browser_focus_guard_unavailable` rather than sent. Agent Tab keys take the same guard, because Tab from the page's last field moves focus out of the guest into Orca's window. + While the user's IME composition is open the pane answers `composing` and main refuses the + input, because the blur would commit the unfinished text (into a terminal, that sends it to + the shell). It is refused rather than held, so a press cannot outlive its agent or land in the + user's next composition. Direct commands report `browser_user_composing`; commands run through + the agent-browser helper, like `click`, report `browser_error` with the same retry message. 4. **Agent keys are marked as the agent's.** While agent input is injected, the guest's Orca shortcut and grab handlers ignore keys and `setIgnoreMenuShortcuts` keeps unhandled keys from Orca's app menu, so an agent's Cmd/Ctrl+T, W or Ctrl+Tab stays in the page. Measured: synthetic input never fired the guest's `before-input-event`; the mark is the guarantee. Known gaps: the user's element is blurred for the length of one agent press, so keys typed in that -window reach the page and an in-progress IME composition is lost. Edit commands (`selectAll`, -`paste`, ...) run even when the page cancels the key. Focus emulation stays on while the debugger -is attached, so a page the user really leaves sees no blur, and it covers the main frame only, so -a menu inside a cross-site frame can still close. A pane that misses the 150 ms ready window -refuses that input. Only offscreen rendering removes the first gap. +window reach the page, and a composition started between the pane's answer and the press is still +committed. Edit commands (`selectAll`, `paste`, ...) run even when the page cancels the key. Focus +emulation stays on while the debugger is attached, so a page the user really leaves sees no blur, +and it covers the main frame only, so a menu inside a cross-site frame can still close. A pane +that misses the 150 ms ready window refuses that input. Only offscreen rendering removes the first +two gaps. ## Tracker @@ -97,3 +103,4 @@ refuses that input. Only offscreen rendering removes the first gap. | Regression spec: user typing in a terminal while an agent clicks, types and presses keys | stack 2/3, 3/3 | in review | | Visible-window spec (`@headful`, isolated display only): native focus stays on Orca's window | stack 3/3 | in review | | Main-session keys and text for a focused cross-site iframe go to its own session, no focus borrow | follow-up PR | in review | +| Agent presses are refused while the user's IME composition is open (#28700) | follow-up PR | in review | diff --git a/docs/reference/remote-snapshot-intent.md b/docs/reference/remote-snapshot-intent.md new file mode 100644 index 000000000000..3b244ef083e8 --- /dev/null +++ b/docs/reference/remote-snapshot-intent.md @@ -0,0 +1,18 @@ +# Snapshots never carry intent + +A client and a host exchange two kinds of payload: a **snapshot** (the whole state as the sender +sees it now, such as a pane layout) and a **delta** (what the user just did). They update +independently, so a snapshot may be resent, replayed or reordered by either side. + +The rule: a snapshot never carries intent. Anything the receiver should treat as a user's action +travels as an explicit, optional delta field that names exactly what changed. + +- A tag on the whole snapshot ("this push was a gesture") can't say which part of it the user + changed, so the receiver either ignores it or applies it to fields the user never touched. +- An optional delta field is safe for older peers: one that doesn't know it drops it and keeps + today's snapshot behaviour. + +Example: `session.tabs.updatePaneLayout` sends the full layout and `titlesByLeafId` as the snapshot, +and `renamedLeafIds` as the delta (the panes the user renamed in this edit). The retired +`intent: 'gesture'` tag is stripped unread by the params schema, so older clients that still send it +keep working. diff --git a/docs/site/README.md b/docs/site/README.md index 8fa37e3cedb9..2305c6025e89 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -9,6 +9,9 @@ This package is a **self-contained Next.js app**. It is intentionally **not** a ## Local development +Use Node.js 24 and the pnpm version pinned in `package.json`. CI reads the +Node.js version from this package's `engines.node` field. + ```bash cd docs/site pnpm install @@ -105,7 +108,8 @@ deployment needs `basePath`, first move the route tree to an unprefixed invokes Vercel from `docs/site`, so that directory is already the deployment root; setting it again would resolve `docs/site/docs/site`. Leave automatic Git deployments disabled so this workflow remains the only deployment path. -2. Framework preset: Next.js. `vercel.json` pins install (`pnpm install --frozen-lockfile`) and build (`pnpm build`) over the dashboard settings; this package has its own lockfile beside the root workspace. +2. Framework preset: Next.js. This package's `engines.node` selects Node.js 24 for + Vercel builds and functions. `vercel.json` pins install (`pnpm install --frozen-lockfile`) and build (`pnpm build`) over the dashboard settings; this package has its own lockfile beside the root workspace. 3. Set GitHub Actions secrets (required by the production deploy job): - `VERCEL_TOKEN` - `VERCEL_ORG_ID` diff --git a/docs/site/content/docs/agents/native-chat.mdx b/docs/site/content/docs/agents/native-chat.mdx index 1a812697e0ad..79800c1810cd 100644 --- a/docs/site/content/docs/agents/native-chat.mdx +++ b/docs/site/content/docs/agents/native-chat.mdx @@ -21,6 +21,8 @@ Toggle Chat UI โ†” terminal from the agent pane once enabled. - Type `/` for slash commands and discovered **skills** (filtered per agent when possible). The slash catalog is agent-aware (Claude vs Codex command sets) and shared with mobile Chat UI. - Use the **model** and options pills (thought level, mode, agent-specific session options) when the agent exposes them. **Claude** model choices come from the **installed Claude CLI on that host** (not a hardcoded list), so new models show up after you update the CLI. **Codex** supports direct model selection from the pill (sends `/model `), with bare `/model` still opening the agent picker. **Grok** exposes **model** (`-m` at launch, `/model` mid-session) and **reasoning effort** (`--reasoning-effort` at launch, `/effort` mid-session); the model list is probed from the signed-in Grok CLI (`grok models`) so retired ids drop out of the picker. Effort choices follow the selected model: Grok 4.6 includes **Extra high**, while Grok 4.5 stops at **High**. **Before the agent process starts**, the composer can show draft launch options that apply on first start; after the session is live, pills that only affect a running turn stay disabled or labeled until the session is ready. Live values go to the CLI in that tab. +OMP launch preferences use the installed CLI's thinking levels, including `off` and `auto` when supported. Leaving thinking unset uses OMP's configuration. The existing `--effort` flag selects native `--thinking` for OMP workers and CLI agent launches; it still requires `--model`. CLI and paired-terminal launches, including every automatic retry and replay after an uncertain launch outcome, refuse an older execution host that cannot preserve explicit thinking rather than silently using its default. Promptless web launches preserve the prepared model and thinking command together with its environment and resume settings when using a legacy terminal host. Model-only launches remain compatible with older hosts. Source Control AI uses the model's advertised thinking choices without inventing a default. Live Chat options still come from ACP. + ## Questions from the agent When Claude shows an **AskUserQuestion** (or similar structured permission/question card), Chat UI should render the question card in the transcript so you can answer from the composer path instead of treating it as a collapsed tool call. Confirm with **Submit** on the card. That includes agents running on a paired [Remote Orca Server](/docs/remote-servers) / headless host โ€” not only local panes. diff --git a/docs/site/package.json b/docs/site/package.json index c8825b3aab1c..51de690cda77 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/mdx": "^2.0.13", - "@types/node": "^22.20.4", + "@types/node": "^24.19.2", "@types/react": "~19.2.18", "@types/react-dom": "~19.2.7", "oxlint": "1.88.0", @@ -37,7 +37,7 @@ "vercel": "64.0.2" }, "engines": { - "node": "22.x" + "node": "24.x" }, "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6" } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 246c915c3f2f..61d346716625 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -186,19 +186,19 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 version: 1.26.0(react@19.2.8) next: specifier: 16.3.8 - version: 16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -222,8 +222,8 @@ importers: specifier: ^2.0.13 version: 2.0.14 '@types/node': - specifier: ^22.20.4 - version: 22.20.4 + specifier: ^24.19.2 + version: 24.19.2 '@types/react': specifier: ~19.2.18 version: 19.2.18 @@ -1979,8 +1979,8 @@ packages: '@types/node@20.11.0': resolution: {integrity: sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==} - '@types/node@22.20.4': - resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} '@types/react-dom@19.2.7': resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} @@ -3730,8 +3730,8 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@6.28.1: resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} @@ -5132,9 +5132,9 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@22.20.4': + '@types/node@24.19.2': dependencies: - undici-types: 6.21.0 + undici-types: 7.24.6 '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: @@ -5949,7 +5949,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -5976,21 +5976,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.18 lucide-react: 1.26.0(react@19.2.8) - next: 16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -6007,12 +6007,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -6028,7 +6028,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) lucide-react: 1.26.0(react@19.2.8) motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -6042,7 +6042,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.18 - next: 16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -6847,7 +6847,7 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - next@16.3.8(@types/node@22.20.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.8(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.3.8 '@swc/helpers': 0.5.23 @@ -6866,7 +6866,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.8 '@next/swc-win32-arm64-msvc': 16.3.8 '@next/swc-win32-x64-msvc': 16.3.8 - sharp: 0.35.5(@types/node@22.20.4) + sharp: 0.35.5(@types/node@24.19.2) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -7284,7 +7284,7 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.35.5(@types/node@22.20.4): + sharp@0.35.5(@types/node@24.19.2): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -7315,7 +7315,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.5 '@img/sharp-win32-ia32': 0.35.5 '@img/sharp-win32-x64': 0.35.5 - '@types/node': 22.20.4 + '@types/node': 24.19.2 optional: true shebang-command@2.0.0: @@ -7464,7 +7464,7 @@ snapshots: undici-types@5.26.5: {} - undici-types@6.21.0: {} + undici-types@7.24.6: {} undici@6.28.1: {} diff --git a/docs/site/tests/docs-package.test.mjs b/docs/site/tests/docs-package.test.mjs index d917759fa41b..71665c98b1b0 100644 --- a/docs/site/tests/docs-package.test.mjs +++ b/docs/site/tests/docs-package.test.mjs @@ -46,7 +46,7 @@ test('docs package has an isolated, reproducible app contract', async () => { assert.equal(packageJson.name, '@orca/docs') assert.equal(packageJson.private, true) assert.equal(packageJson.packageManager.split('+')[0], 'pnpm@12.14.0') - assert.equal(packageJson.engines.node, '22.x') + assert.equal(packageJson.engines.node, '24.x') assert.equal(packageJson.scripts.build, 'next build') assert.equal(packageJson.scripts.postinstall, 'fumadocs-mdx') assert.equal(packageJson.scripts.prebuild, 'fumadocs-mdx') diff --git a/mobile/src/host-screen/host-pending-creation-row.tsx b/mobile/src/host-screen/host-pending-creation-row.tsx new file mode 100644 index 000000000000..1477bbed666f --- /dev/null +++ b/mobile/src/host-screen/host-pending-creation-row.tsx @@ -0,0 +1,55 @@ +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import type { SectionPendingCreation } from '../worktree/workspace-list-types' + +/** A workspace the host is creating, where the desktop's sidebar shows its pending row. */ +export function HostPendingCreationRow({ creation }: { creation: SectionPendingCreation }) { + return ( + + + + + + + Creating workspaceโ€ฆ + + {creation.repoName ? ( + + {creation.repoName} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + // The workspace row's padding and indicator column, so the finished row lands in place. + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm + 2, + paddingLeft: spacing.lg, + paddingRight: spacing.lg, + borderLeftWidth: 2, + borderLeftColor: 'transparent' + }, + indicator: { + width: 20, + alignItems: 'center', + marginRight: spacing.sm + }, + main: { + flex: 1 + }, + title: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary + }, + meta: { + marginTop: 2, + color: colors.textMuted, + fontSize: typography.metaSize + } +}) diff --git a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts index 62b39abbe0eb..3120af8be1fd 100644 --- a/mobile/src/host-screen/host-server-workspaces-wiring.test.ts +++ b/mobile/src/host-screen/host-server-workspaces-wiring.test.ts @@ -29,7 +29,7 @@ import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' -import { useHostDisplayWorktrees } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useResolvedHostPlatform } from './use-host-display-worktrees' import { useHostScreenState } from './use-host-screen-state' import { useHostWorktreeActions } from './use-host-worktree-actions' import { useHostWorktreeCatalog } from './use-host-worktree-catalog' @@ -154,7 +154,11 @@ async function mountScreen(first: RpcClient, hostCapabilities: string[]): Promis }) held.state = state held.fetch = catalog.fetchWorktrees - held.rows = useHostDisplayWorktrees('connected', state, 'host-1') + held.rows = useHostDisplayWorktrees( + 'connected', + state, + useResolvedHostPlatform(state, 'host-1') + ) return null } await act(async () => { diff --git a/mobile/src/host-screen/host-sidebar-feed-inputs.ts b/mobile/src/host-screen/host-sidebar-feed-inputs.ts index d55d402b8d21..17d856497618 100644 --- a/mobile/src/host-screen/host-sidebar-feed-inputs.ts +++ b/mobile/src/host-screen/host-sidebar-feed-inputs.ts @@ -1,10 +1,10 @@ import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, - getLocalExecutionHostLabel, normalizeExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' +import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' import type { DesktopSidebarFacts } from '../../../src/shared/sidebar-list/desktop-sidebar-facts' import type { ProjectGroup } from '../../../src/shared/project-group-types' import type { Repo } from '../../../src/shared/repo-types' @@ -37,9 +37,6 @@ export function buildHostFeedView(args: { return id ? [[id, label] as const] : [] }) ) - if (hostPlatform && !hostLabelOverrides.has(LOCAL_EXECUTION_HOST_ID)) { - hostLabelOverrides.set(LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel(hostPlatform)) - } return { groupBy: view.groupBy, sortBy: view.sortBy, @@ -51,7 +48,9 @@ export function buildHostFeedView(args: { workspaceStatuses: view.workspaceStatuses, pinnedDisplayPolicy: desktop?.showPinnedInGroups ? 'duplicate-in-groups' : 'single-location', defaultHostId: normalizeExecutionHostId(desktop?.focusedHostId) ?? LOCAL_EXECUTION_HOST_ID, - hostLabelOverrides + hostLabelOverrides, + // Null platform names no OS ("This computer"); the phone's own never stands in for it. + localHostLabel: getHostContextLabel(LOCAL_EXECUTION_HOST_ID, { hostPlatform }) } } diff --git a/mobile/src/host-screen/host-sidebar-feed-reader.test.ts b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts index 828f6096690a..f0d8b77a81fb 100644 --- a/mobile/src/host-screen/host-sidebar-feed-reader.test.ts +++ b/mobile/src/host-screen/host-sidebar-feed-reader.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { SidebarInputsFeed } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed' import { testDomain } from '../../../src/main/runtime/sidebar-inputs/sidebar-inputs-feed.test-fixture' -import type { SidebarInputsChangedEvent } from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' +import { + SIDEBAR_INPUTS_SEMANTICS_VERSION, + type SidebarInputsChangedEvent +} from '../../../src/shared/sidebar-list/sidebar-inputs-feed-contract' import type { RpcResponse } from '../transport/types' import type { UnvalidatedRpcRequestPort } from '../transport/unvalidated-rpc-request-port' import { HostSidebarFeedReader, type HostSidebarFeedRead } from './host-sidebar-feed-reader' @@ -108,7 +111,7 @@ describe('readHostSidebarFeed', () => { generation: 'g', revision: 3, schemaVersion: 1, - semanticsVersion: 1, + semanticsVersion: SIDEBAR_INPUTS_SEMANTICS_VERSION, domains: new Map() } @@ -121,7 +124,16 @@ describe('readHostSidebarFeed', () => { it('asks to reopen when the host means other things by its inputs', () => { expect( - readHostSidebarFeed({ installed: { ...installed, semanticsVersion: 2 }, unavailable: false }) + readHostSidebarFeed({ + installed: { ...installed, semanticsVersion: SIDEBAR_INPUTS_SEMANTICS_VERSION + 1 }, + unavailable: false + }) + ).toEqual({ kind: 'reopen' }) + }) + + it('asks a host from before desktop facts carried focus and labels to reopen', () => { + expect( + readHostSidebarFeed({ installed: { ...installed, semanticsVersion: 1 }, unavailable: false }) ).toEqual({ kind: 'reopen' }) }) }) diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts index 522896bf88ab..90951e88b090 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.test.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.test.ts @@ -4,20 +4,25 @@ import { type SidebarFeedView } from '../../../src/shared/sidebar-list/sidebar-feed-rows' import type { ExecutionHostScope } from '../../../src/shared/execution-host' +import type { SidebarFeedDomains } from '../../../src/shared/sidebar-list/sidebar-feed-domains' import { resolveVisibleWorkspaceHostIds } from '../../../src/shared/workspace-view/workspace-host-visibility' import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs' -import { buildHostFeedSections } from './host-sidebar-feed-sections' +import { buildHostFeedSections, buildPreFeedSections } from './host-sidebar-feed-sections' import { SERVED_SECTIONS, feedDomains, liveRow, phoneView } from './host-sidebar-feed.test-fixture' import type { MobileViewState } from '../worktree/workspace-view-settings' const catalog = buildHostFeedCatalog(SERVED_SECTIONS, [], []) -function phoneFeedView(view: MobileViewState, showPinnedInGroups = false): SidebarFeedView { +function phoneFeedView( + view: MobileViewState, + showPinnedInGroups = false, + hostPlatform: NodeJS.Platform | null = 'darwin' +): SidebarFeedView { return buildHostFeedView({ view, projectOrderBy: 'manual', desktop: { ...feedDomains().desktop!, showPinnedInGroups }, - hostPlatform: 'darwin' + hostPlatform }) } @@ -43,6 +48,17 @@ function sectionsFor( }) } +function sectionsFromDomains(domains: SidebarFeedDomains, view: MobileViewState, search = '') { + const built = buildSidebarFeedRows(domains, catalog, phoneFeedView(view)) + return buildHostFeedSections({ + rows: built.sectionRows, + activeRowKeys: built.activeRowKeys, + liveRows: [], + repoHostIdByRepoId: new Map(), + search + }) +} + function outline(sections: ReturnType): string[] { return sections.flatMap((section) => [ `${section.key} "${section.title}" ${String(section.count)}${section.icon ? ' pin' : ''}`, @@ -94,22 +110,34 @@ describe('the phone list from the sidebar feed', () => { } }) - it('names the desktopโ€™s own host by its platform, not the phoneโ€™s', () => { - const view = phoneView({ - filters: { ...phoneView().filters, visibleHostIds: ['local', 'ssh:gpu'] } - }) - const built = buildSidebarFeedRows(feedDomains(), catalog, phoneFeedView(view)) - const sections = buildHostFeedSections({ - rows: built.sectionRows, - activeRowKeys: built.activeRowKeys, - liveRows: [], - repoHostIdByRepoId: new Map(), - search: '' - }) - expect(sections.map((section) => section.title)).toEqual( - expect.arrayContaining(['Local Mac', 'GPU box']) - ) - }) + // The test process runs on its own platform, which plays the phone's here. + it.each([ + ['darwin', 'Local Mac'], + ['win32', 'Local Windows'], + [null, 'This computer'] + ] as const)( + 'names a %s desktopโ€™s own host "%s", never by the phoneโ€™s platform', + (platform, label) => { + const view = phoneView({ + filters: { ...phoneView().filters, visibleHostIds: ['local', 'ssh:gpu'] } + }) + const built = buildSidebarFeedRows( + feedDomains(), + catalog, + phoneFeedView(view, false, platform) + ) + const sections = buildHostFeedSections({ + rows: built.sectionRows, + activeRowKeys: built.activeRowKeys, + liveRows: [], + repoHostIdByRepoId: new Map(), + search: '' + }) + expect(sections.map((section) => section.title)).toEqual( + expect.arrayContaining([label, 'GPU box']) + ) + } + ) it('draws each row with the live content worktree.ps served, matched by host', () => { const sections = sectionsFor(phoneView(), { @@ -153,6 +181,45 @@ describe('the phone list from the sidebar feed', () => { }) }) + it('draws a creation the host is running under its project, until the host lists it', () => { + const creating = (worktreeId?: string) => + sectionsFromDomains( + feedDomains({ + creations: new Map([['c1', { repoId: 'lab', ...(worktreeId ? { worktreeId } : {}) }]]) + }), + phoneView() + ).find((section) => section.title === 'lab')! + expect(creating().pendingCreations).toEqual([{ key: 'pending:c1', repoName: null }]) + expect(creating().data.map((row) => row.worktreeId)).toEqual(['lab::/w/gpu']) + // Once the host names a workspace it lists, only the workspace shows. + expect(creating('lab::/w/gpu').pendingCreations).toBeUndefined() + }) + + it('draws creations above every group when not grouped by project', () => { + const [first] = sectionsFromDomains( + feedDomains({ creations: new Map([['c1', { repoId: 'orca' }]]) }), + phoneView({ groupBy: 'none' }) + ) + expect(first).toMatchObject({ title: '', pendingCreations: [{ repoName: 'orca' }] }) + }) + + it('drops a creation from a section search narrows to its matching rows', () => { + const domains = feedDomains({ creations: new Map([['c1', { repoId: 'lab' }]]) }) + const lab = sectionsFromDomains(domains, phoneView(), 'gpu').find( + (section) => section.title === 'lab' + ) + expect(lab?.data.map((row) => row.worktreeId)).toEqual(['lab::/w/gpu']) + expect(lab?.pendingCreations).toBeUndefined() + }) + + it('keeps a creation under its project header when the search matches that header', () => { + const domains = feedDomains({ creations: new Map([['c1', { repoId: 'lab' }]]) }) + const lab = sectionsFromDomains(domains, phoneView(), 'lab').find( + (section) => section.title === 'lab' + ) + expect(lab?.pendingCreations).toEqual([{ key: 'pending:c1', repoName: null }]) + }) + it('narrows rows by search and drops sections left with none', () => { const sections = sectionsFor(phoneView(), { search: 'gpu' }) expect(sections.flatMap((section) => section.data.map((row) => row.worktreeId))).toEqual([ @@ -161,3 +228,22 @@ describe('the phone list from the sidebar feed', () => { expect(sections.map((section) => section.title)).toEqual(['lab']) }) }) + +describe('the phone list before the first feed read', () => { + const cached = [ + liveRow({ worktreeId: 'orca::/w/b', displayName: 'beta' }), + liveRow({ worktreeId: 'orca::/w/a', displayName: 'alpha' }), + liveRow({ worktreeId: 'orca::/w/old', displayName: 'old', isArchived: true }) + ] + const preFeed = (search: string, rows = cached) => + buildPreFeedSections({ rows, view: phoneView(), search, repoHostIdByRepoId: new Map() }) + + it('shows the rows the page last listed, in their order under no header, filtered as the view is', () => { + expect(preFeed('')).toEqual([{ key: 'all', title: '', data: cached.slice(0, 2) }]) + }) + + it('narrows them by search, and draws nothing with none', () => { + expect(preFeed('alp')[0]?.data.map((row) => row.worktreeId)).toEqual(['orca::/w/a']) + expect(preFeed('', [])).toEqual([]) + }) +}) diff --git a/mobile/src/host-screen/host-sidebar-feed-sections.ts b/mobile/src/host-screen/host-sidebar-feed-sections.ts index afe5148063d2..81548bfc0ab9 100644 --- a/mobile/src/host-screen/host-sidebar-feed-sections.ts +++ b/mobile/src/host-screen/host-sidebar-feed-sections.ts @@ -12,6 +12,8 @@ import type { Worktree as HostWorktree } from '../../../src/shared/worktree/type import { composeWorktreeHostIdentity } from '../../../src/shared/worktree/host-qualified-identity' import type { Section, Worktree } from '../worktree/workspace-list-types' import { resolveWorktreeHostId } from '../worktree/worktree-host-context-labels' +import { filterWorktrees } from '../worktree/workspace-list-sections' +import { rowFiltersForView, type MobileViewState } from '../worktree/workspace-view-settings' /** What the host screen draws from a feed-built list, besides its rows' own live content. */ export type HostFeedListSources = { @@ -170,8 +172,20 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { }) break } - // A creation in progress and the desktop-only cards draw nothing on the phone. - case 'pending-creation': + case 'pending-creation': { + const section = current() + section.pendingCreations ??= [] + section.pendingCreations.push({ + key: row.key, + // A project header already names it. + repoName: + section.repo && section.repo.id === row.repo?.id + ? null + : (row.repo?.displayName ?? null) + }) + break + } + // The desktop-only cards draw nothing on the phone. case 'imported-worktrees-card': case 'new-external-worktrees-inbox': break @@ -180,6 +194,26 @@ export function buildHostFeedSections(sources: HostFeedListSources): Section[] { return query ? narrowSections(sections, query) : sections } +/** + * Before the first feed read: the rows this page last listed (worktree.ps, or the cache the screen + * opened with) under the view's filters, in their served order and no header, so an offline open + * is not blank. + */ +export function buildPreFeedSections(sources: { + rows: Worktree[] + view: MobileViewState + search: string + repoHostIdByRepoId: ReadonlyMap +}): Section[] { + const rows = filterWorktrees( + sources.rows, + rowFiltersForView(sources.view), + sources.search, + sources.repoHostIdByRepoId + ) + return rows.length > 0 ? [{ key: 'all', title: '', data: rows }] : [] +} + /** A section whose title matches keeps every row; otherwise only matching rows, counted as shown. */ function narrowSections(sections: readonly Section[], query: string): Section[] { return sections.flatMap((section) => { @@ -187,6 +221,9 @@ function narrowSections(sections: readonly Section[], query: string): Section[] return [section] } const data = section.data.filter((row) => matchesSearch(row, query)) - return data.length > 0 ? [{ ...section, data, count: data.length }] : [] + // Only matching rows: a creation has no name to match yet. + return data.length > 0 + ? [{ ...section, data, count: data.length, pendingCreations: undefined }] + : [] }) } diff --git a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts index 1d9d822d1d53..7a7500dcb985 100644 --- a/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts +++ b/mobile/src/host-screen/host-ssh-health-badge-wiring.test.ts @@ -13,7 +13,7 @@ vi.mock('../cache/repo-cache', () => ({ setCachedRepos: () => {} })) import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' import type { RpcResponse } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' -import { useHostDisplayWorktrees } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useResolvedHostPlatform } from './use-host-display-worktrees' import { useHostRepoMetadata } from './use-host-repo-metadata' import { useHostScreenState } from './use-host-screen-state' @@ -69,7 +69,11 @@ async function displayedRows(args: { state.clientRef.current = client setWorktrees = state.setWorktrees seen.refresh = useHostRepoMetadata({ client, connState: 'connected', hostId: 'host-1', state }) - seen.rows = useHostDisplayWorktrees('connected', state, 'host-1') + seen.rows = useHostDisplayWorktrees( + 'connected', + state, + useResolvedHostPlatform(state, 'host-1') + ) return null } await act(async () => { diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 7760f3d46744..67117c98f901 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -27,6 +27,7 @@ import { repoColor } from '../worktree/repo-color' import { getWorktreeCardDetails } from '../../../src/shared/worktree/card-properties' import { hostScreenStyles as styles } from './host-screen-styles' import { buildRepoLookById } from './host-repo-looks' +import { HostPendingCreationRow } from './host-pending-creation-row' import type { HostScreenController } from './use-host-screen-controller' export function HostWorkspaceList({ controller }: { controller: HostScreenController }) { @@ -169,8 +170,11 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro !embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } ]} renderSectionHeader={({ section }) => { + const pendingCreations = section.pendingCreations?.map((creation) => ( + + )) if (!section.title) { - return null + return pendingCreations ? <>{pendingCreations} : null } const isCollapsed = controller.collapsedGroups.has(section.key) const count = @@ -183,37 +187,40 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro const repoSectionColor = repoSectionLook?.color ?? null const repoSectionIcon = repoSectionLook?.icon ?? null return ( - settings.toggleCollapsed(section.key)} - > - {isCollapsed ? ( - - ) : ( - - )} - {section.icon === 'pin' && ( - - )} - {state.view.groupBy === 'repo' ? ( - - + settings.toggleCollapsed(section.key)} + > + {isCollapsed ? ( + + ) : ( + + )} + {section.icon === 'pin' && ( + + )} + {state.view.groupBy === 'repo' ? ( + + + + ) : null} + {section.title} + {count} + {pendingCollapseKeys.has(section.key) && ( + - - ) : null} - {section.title} - {count} - {pendingCollapseKeys.has(section.key) && ( - - )} - + )} + + {pendingCreations} + ) }} ItemSeparatorComponent={ListSeparator} diff --git a/mobile/src/host-screen/use-host-display-worktrees.ts b/mobile/src/host-screen/use-host-display-worktrees.ts index 4ea249851c3b..a7aa39628f6c 100644 --- a/mobile/src/host-screen/use-host-display-worktrees.ts +++ b/mobile/src/host-screen/use-host-display-worktrees.ts @@ -6,11 +6,21 @@ import { applyWorktreeHostContextLabels } from '../worktree/worktree-host-contex import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity' import type { HostScreenState } from './use-host-screen-state' +/** The desktop's platform: its own report, or the paired descriptor's before that lands. */ +export function useResolvedHostPlatform( + state: HostScreenState, + hostId: string | undefined +): NodeJS.Platform | null { + const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null + return state.hostPlatform ?? descriptorPlatform +} + /** The rows the host list renders: the live or last-known catalog with display state and host badges. */ export function useHostDisplayWorktrees( connState: ConnectionState, state: HostScreenState, - hostId: string | undefined + /** Metadata reads it only for a mixed desktop catalog; server rows mix it too. */ + hostPlatform: NodeJS.Platform | null ) { const { serverWorkspaces } = state // Why: a server's label and health come with its rows, alongside the desktop's SSH ones. @@ -22,9 +32,6 @@ export function useHostDisplayWorktrees( () => withServerHosts(state.hostHealthById, serverWorkspaces.hosts, serverHostHealth), [state.hostHealthById, serverWorkspaces.hosts] ) - const descriptorPlatform = useHostDescriptor(hostId)?.platform ?? null - // Why: metadata reads the platform only for a mixed desktop catalog; server rows mix it too. - const hostPlatform = state.hostPlatform ?? descriptorPlatform return useMemo(() => { // Why: live `worktrees` is authoritative only while connected; under the amber // mount default, connecting/handshaking must keep the pre-reconnect list too. diff --git a/mobile/src/host-screen/use-host-list-sections.ts b/mobile/src/host-screen/use-host-list-sections.ts index e3c40b500f30..272592ba5d65 100644 --- a/mobile/src/host-screen/use-host-list-sections.ts +++ b/mobile/src/host-screen/use-host-list-sections.ts @@ -10,7 +10,7 @@ import { buildFilterRepos, type WorkspaceSectionRepo } from '../worktree/workspa import type { Section, SectionRepo, Worktree } from '../worktree/workspace-list-types' import { rowFiltersForView } from '../worktree/workspace-view-settings' import { buildHostFeedCatalog, buildHostFeedView } from './host-sidebar-feed-inputs' -import { buildHostFeedSections } from './host-sidebar-feed-sections' +import { buildHostFeedSections, buildPreFeedSections } from './host-sidebar-feed-sections' import { useHostRepoIconDataUrls } from './use-host-repo-icon-data-urls' import type { HostSidebarFeed } from './use-host-sidebar-feed' import type { HostScreenState } from './use-host-screen-state' @@ -21,6 +21,8 @@ export type HostListSectionsInputs = { feed: HostSidebarFeed | null /** The rows worktree.ps and the desktop's server lists served, with display state applied. */ displayWorktrees: Worktree[] + /** The desktop's platform, which names its own host; never the phone's. */ + hostPlatform: NodeJS.Platform | null state: HostScreenState } @@ -97,6 +99,7 @@ function useFeedHostListSections({ client, feed, displayWorktrees, + hostPlatform, state }: HostListSectionsInputs): HostListSections { const collapsedGroups = useCollapsedGroups(state) @@ -119,14 +122,15 @@ function useFeedHostListSections({ view: state.view, projectOrderBy, desktop: domains?.desktop ?? null, - hostPlatform: state.hostPlatform + hostPlatform }), - [state.view, projectOrderBy, domains, state.hostPlatform] + [state.view, projectOrderBy, domains, hostPlatform] ) const built = useMemo( () => (domains ? buildSidebarFeedRows(domains, catalog, view) : null), [domains, catalog, view] ) + const loading = feed?.kind === 'loading' const sections = useMemo( () => built @@ -137,8 +141,15 @@ function useFeedHostListSections({ repoHostIdByRepoId: state.repoHostIdByRepoId, search: state.search }) - : NO_SECTIONS, - [built, displayWorktrees, state.repoHostIdByRepoId, state.search] + : loading + ? buildPreFeedSections({ + rows: displayWorktrees, + view: state.view, + search: state.search, + repoHostIdByRepoId: state.repoHostIdByRepoId + }) + : NO_SECTIONS, + [built, loading, displayWorktrees, state.view, state.repoHostIdByRepoId, state.search] ) const iconIds = useMemo(() => [...new Set(domains?.repoIcons.values() ?? [])], [domains]) diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 9507e41069c0..4f61206c064f 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -18,7 +18,7 @@ import { useReconnectAttempt, useRelayRecoveryStatus } from '../transport/client-context-connection-metrics' -import { useHostDisplayWorktrees } from './use-host-display-worktrees' +import { useHostDisplayWorktrees, useResolvedHostPlatform } from './use-host-display-worktrees' import { useHostListSections } from './use-host-list-sections' import { useSwitchedHostSidebarFeed } from './use-host-sidebar-feed' import { useHostProjectSections } from './use-host-project-sections' @@ -120,9 +120,16 @@ export function useHostScreenController({ } const showNewWorktree = resolvedRouteActionState.showNewWorktree - const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId) + const hostPlatform = useResolvedHostPlatform(state, hostId) + const displayWorktrees = useHostDisplayWorktrees(connState, state, hostPlatform) const feed = sidebarFeed?.feed ?? null - const sectionsResult = useHostListSections({ client, feed, displayWorktrees, state }) + const sectionsResult = useHostListSections({ + client, + feed, + displayWorktrees, + hostPlatform, + state + }) const { collapsedGroups, projectOrderBy } = sectionsResult const { uniqueRepos } = sectionsResult const activeFilterCount = useMemo( diff --git a/mobile/src/session/mobile-native-chat-page-rejection.test.ts b/mobile/src/session/mobile-native-chat-page-rejection.test.ts index 2b079b1cdd6e..2453742fd7a6 100644 --- a/mobile/src/session/mobile-native-chat-page-rejection.test.ts +++ b/mobile/src/session/mobile-native-chat-page-rejection.test.ts @@ -45,7 +45,8 @@ async function unhandledRejectionsWhile(run: () => Promise): Promise { ]) }) }) + it('does not add launch-only thinking options to terminal model choices', async () => { + await mount({ + sendRequest: vi.fn(async () => + reply({ + ...listed, + models: [ + { + id: reportedModel, + label: 'Current', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' } + ] + } + ] + }) + ) + }) + expect(snapshot.map(({ id }) => id)).toEqual(['model']) + }) it('makes discovered choices selectable only after the live extension advertises support', async () => { await mount({ sendRequest: vi.fn(async () => reply(listed)) }) expect(snapshot[0]?.settable).toBe(true) diff --git a/mobile/src/test-support/rpc-recording/unhandled-recording.ts b/mobile/src/test-support/rpc-recording/unhandled-recording.ts index a4bd0e033d48..635f1dea33fe 100644 --- a/mobile/src/test-support/rpc-recording/unhandled-recording.ts +++ b/mobile/src/test-support/rpc-recording/unhandled-recording.ts @@ -17,7 +17,8 @@ export function recordUnhandledRejections( active = false process.removeAllListeners('unhandledRejection') for (const listener of previous) { - process.on('unhandledRejection', listener) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: raw listeners handle this event; preserve their wrappers so once listeners still fire once. + process.on('unhandledRejection', listener as NodeJS.UnhandledRejectionListener) } } } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-attach-timeout.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-attach-timeout.test.ts new file mode 100644 index 000000000000..a2084a8af03e --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-attach-timeout.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_RELAY_PHONE_CLOSE_REASON } from '../../../src/shared/mobile-relay-close-codes' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + dependencies, + FakeLogicalClient, + FakeRelaySession, + host, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +// The fakes' fixed jitter puts the known-offline wait at 5039ms. +const HOST_OFFLINE_WAIT_MS = 5_039 +// ...and the attach-timeout redial at 1003ms. +const ATTACH_TIMEOUT_REDIAL_MS = 1_003 + +function attachTimeout(): RelayOuterError { + return new RelayOuterError(4404, true, MOBILE_RELAY_PHONE_CLOSE_REASON.HOST_ATTACH_TIMEOUT) +} + +// A cell that refuses every relay dial with `failure` until `succeedAfter` dials. +function cellRefusing(failure: () => Error, succeedAfter = Number.POSITIVE_INFINITY) { + const logical = new FakeLogicalClient('disconnected', 'lan') + const migrateTo = logical.migrateTo.getMockImplementation()! + let dials = 0 + logical.migrateTo.mockImplementation(async (session: RpcClient, ...rest) => { + dials += 1 + if (dials > succeedAfter) { + return migrateTo(session, ...rest) + } + session.close() + throw failure() + }) + const openRelay = vi.fn( + () => new FakeRelaySession('connected') + ) + return { logical, openRelay } +} + +function start( + logical: FakeLogicalClient, + overrides: Partial +): MobileEndpointSupervisor { + const supervisor = new MobileEndpointSupervisor( + logical, + host.id, + relay, + dependencies({ + // Why: a reachable direct path would win and end relay recovery mid-test. + openDirect: vi.fn(() => new FakeRelaySession('disconnected')), + ...overrides + }) + ) + return supervisor +} + +describe('relay dial refused because the desktop data link missed its attach deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('redials the same cell after a short delay without asking the director', async () => { + const { logical, openRelay } = cellRefusing(attachTimeout, 1) + const resolveRelay = vi.fn(async () => relay) + const supervisor = start(logical, { openRelay, resolveRelay }) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(ATTACH_TIMEOUT_REDIAL_MS - 1) + expect(openRelay).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay.mock.calls[1]?.[0].cellUrl).toBe(relay.cellUrl) + expect(resolveRelay).not.toHaveBeenCalled() + expect(logical.getState()).toBe('connected') + supervisor.stop() + }) + + it('falls back to the known-offline wait when the redial fails the same way', async () => { + const { logical, openRelay } = cellRefusing(attachTimeout) + const resolveRelay = vi.fn(async () => relay) + const supervisor = start(logical, { openRelay, resolveRelay }) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(ATTACH_TIMEOUT_REDIAL_MS) + expect(openRelay).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(HOST_OFFLINE_WAIT_MS - 1) + expect(openRelay).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(openRelay).toHaveBeenCalledTimes(3) + // The quick redial is spent for this failure streak. + await vi.advanceTimersByTimeAsync(HOST_OFFLINE_WAIT_MS - 1) + expect(openRelay).toHaveBeenCalledTimes(3) + expect(resolveRelay).not.toHaveBeenCalled() + supervisor.stop() + }) + + it('keeps the known-offline wait for a 4404 without the reason', async () => { + const { logical, openRelay } = cellRefusing(() => new RelayOuterError(4404, true)) + const resolveRelay = vi.fn(async () => relay) + const supervisor = start(logical, { openRelay, resolveRelay }) + + await supervisor.start() + + expect(resolveRelay).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(HOST_OFFLINE_WAIT_MS - 1) + expect(openRelay).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(openRelay).toHaveBeenCalledTimes(2) + supervisor.stop() + }) + + it('earns a fresh quick redial when the app returns to the foreground', async () => { + const { logical, openRelay } = cellRefusing(attachTimeout) + const supervisor = start(logical, { openRelay }) + await supervisor.start() + await vi.advanceTimersByTimeAsync(ATTACH_TIMEOUT_REDIAL_MS) + expect(openRelay).toHaveBeenCalledTimes(2) + + supervisor.setForeground(false) + supervisor.setForeground(true) + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(ATTACH_TIMEOUT_REDIAL_MS) + + expect(openRelay).toHaveBeenCalledTimes(4) + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 70bf1f3aae25..85e320618ce2 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -69,6 +69,7 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi resumeExpiresAt: this.resumeExpiry }) getFailure = () => this.failure + readonly probeAfterBackground = vi.fn() } export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { diff --git a/mobile/src/transport/mobile-relay-background-lifecycle.test.ts b/mobile/src/transport/mobile-relay-background-lifecycle.test.ts index 53e44f383b82..9df36203aa11 100644 --- a/mobile/src/transport/mobile-relay-background-lifecycle.test.ts +++ b/mobile/src/transport/mobile-relay-background-lifecycle.test.ts @@ -41,6 +41,34 @@ describe('mobile Relay background lifecycle', () => { supervisor.stop() }) + it('probes a retained relay once when the app really returns from the background', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const session = new FakeRelaySession('connected') + const deps = dependencies({ + openDirect: vi.fn(() => new FakeSession('disconnected')), + openRelay: vi.fn(() => session) + }) + const supervisor = new MobileEndpointSupervisor(logical, host.id, relay, deps) + await supervisor.start() + expect(logical.getActivePath()).toBe('relay') + + // A foreground nudge (iOS inactiveโ†’active, manual reconnect) is not a background return. + supervisor.nudge('app-resume') + expect(session.probeAfterBackground).not.toHaveBeenCalled() + + supervisor.setForeground(false) + await vi.advanceTimersByTimeAsync(5_000) + supervisor.setForeground(true) + expect(session.probeAfterBackground).toHaveBeenCalledOnce() + + // Past the grace the relay is already released, so there is nothing to probe. + supervisor.setForeground(false) + await vi.advanceTimersByTimeAsync(30_000) + supervisor.setForeground(true) + expect(session.probeAfterBackground).toHaveBeenCalledOnce() + supervisor.stop() + }) + it('releases a retained relay after 30 seconds and recovers only on foreground', async () => { const logical = new FakeLogicalClient('connected', 'relay') const deps = dependencies() diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index 965135511eb4..8cd5393b5537 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -16,7 +16,8 @@ vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ } })) -import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' +import { MOBILE_RELAY_PHONE_CLOSE_REASON } from '../../../src/shared/mobile-relay-close-codes' +import { MobileRelayE2eeLink, RelayOuterError } from './mobile-relay-e2ee-link' class ThrowingSocket { static readonly OPEN = 1 @@ -26,7 +27,7 @@ class ThrowingSocket { onopen: (() => void) | null = null onmessage: ((event: { data: unknown }) => void) | null = null onerror: (() => void) | null = null - onclose: ((event: { code: number }) => void) | null = null + onclose: ((event: { code: number; reason?: string }) => void) | null = null send = vi.fn(() => { throw new Error('relay auth write failed') }) @@ -224,4 +225,70 @@ describe('MobileRelayE2eeLink', () => { vi.useRealTimers() } }) + + it('carries the close reason of a relay-hello refusal that arrives first', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + openLink(socket, onError) + socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }) }) + await vi.advanceTimersByTimeAsync(0) + expect(onError).not.toHaveBeenCalled() + + socket.onclose?.({ code: 4404, reason: MOBILE_RELAY_PHONE_CLOSE_REASON.HOST_ATTACH_TIMEOUT }) + + const error: unknown = onError.mock.calls[0]?.[0] + expect(error).toBeInstanceOf(RelayOuterError) + expect(error).toMatchObject({ + code: 4404, + rejectedByRelayHello: true, + closeReason: 'attach-timeout' + }) + } finally { + vi.useRealTimers() + } + }) + + it('fails a relay-hello refusal whose close never arrives', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + openLink(socket, onError) + socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }) }) + await vi.advanceTimersByTimeAsync(249) + expect(onError).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + code: 4404, + rejectedByRelayHello: true, + closeReason: '' + }) + expect(socket.close).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) }) + +function openLink(socket: ThrowingSocket, onError: (error: Error) => void): MobileRelayE2eeLink { + return new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the link only calls send/close and assigns handlers on this stub. + createSocket: () => socket as unknown as WebSocket + }) +} diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index ed1daf025085..2c047773a816 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -12,15 +12,17 @@ import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' -// Native WebSockets normally emit close immediately after error; bound the -// missing-close case so a dead socket cannot leave recovery pending forever. +// Native WebSockets normally emit close immediately after error or a relay-hello +// refusal; bound the missing-close case so recovery is never left pending. const RELAY_ERROR_CLOSE_GRACE_MS = 250 export class RelayOuterError extends Error { // rejectedByRelayHello: the relay refused in a relay-hello frame, not just a close code. + // closeReason: the close frame's reason text; free text, so only compare exact values. constructor( readonly code: number, - readonly rejectedByRelayHello = false + readonly rejectedByRelayHello = false, + readonly closeReason = '' ) { super(`relay_outer_${code}`) } @@ -54,7 +56,8 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false - private transportErrorTimer: ReturnType | null = null + private rejectedHelloCode: number | null = null + private closeGraceTimer: ReturnType | null = null private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { @@ -96,10 +99,7 @@ export class MobileRelayE2eeLink { return } this.closed = true - if (this.transportErrorTimer) { - clearTimeout(this.transportErrorTimer) - this.transportErrorTimer = null - } + this.clearCloseGrace() this.channel.dispose() this.socket.close() } @@ -124,7 +124,7 @@ export class MobileRelayE2eeLink { this.socket.onmessage = (event) => { this.inboundChain = this.inboundChain .then(async () => { - if (this.closed) { + if (this.closed || this.rejectedHelloCode !== null) { return } if (!this.outerReady) { @@ -138,25 +138,42 @@ export class MobileRelayE2eeLink { // `error` is often delivered just before `close`; wait for close so a // typed relay code is not replaced by a generic transport error. this.socket.onerror = () => { - if (this.closed) { - return + if (!this.closed) { + this.awaitClose() } - this.transportErrorTimer ??= setTimeout(() => { - this.transportErrorTimer = null - this.fail(new RelayOuterError(1006)) - }, RELAY_ERROR_CLOSE_GRACE_MS) } this.socket.onclose = (event) => { - if (this.transportErrorTimer) { - clearTimeout(this.transportErrorTimer) - this.transportErrorTimer = null - } - // Ahead of fail(), which no-ops once the hello already reported this close. + this.clearCloseGrace() + // Ahead of fail(), which no-ops once a missing close already timed out. const hostCloseReason = relayHostCloseReasonFrom(event.reason) if (hostCloseReason) { this.options.onHostCloseReason?.(hostCloseReason) } - this.fail(new RelayOuterError(event.code || 1006)) + this.fail( + new RelayOuterError( + this.rejectedHelloCode ?? (event.code || 1006), + this.rejectedHelloCode !== null, + event.reason ?? '' + ) + ) + } + } + + // Waits briefly for the close frame, which alone carries the reason, before + // failing with what is known; a dead socket may never deliver it. + private awaitClose(): void { + this.closeGraceTimer ??= setTimeout(() => { + this.closeGraceTimer = null + this.fail( + new RelayOuterError(this.rejectedHelloCode ?? 1006, this.rejectedHelloCode !== null) + ) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + + private clearCloseGrace(): void { + if (this.closeGraceTimer) { + clearTimeout(this.closeGraceTimer) + this.closeGraceTimer = null } } @@ -175,7 +192,11 @@ export class MobileRelayE2eeLink { throw new Error('invalid relay hello') } if (!parsed.data.ok) { - throw new RelayOuterError(parsed.data.code, true) + // Why: the cell closes right behind a refusal, and only that close carries + // the reason recovery needs; failing now would drop it. + this.rejectedHelloCode = parsed.data.code + this.awaitClose() + return } if (parsed.data.credentialKind !== this.options.expectedCredentialKind) { throw new Error('relay credential resolved as an unexpected credential kind') @@ -190,10 +211,7 @@ export class MobileRelayE2eeLink { return } this.closed = true - if (this.transportErrorTimer) { - clearTimeout(this.transportErrorTimer) - this.transportErrorTimer = null - } + this.clearCloseGrace() this.channel.dispose() this.options.onError(error) this.socket.close() diff --git a/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts index 4727f34646e7..0d50f432079f 100644 --- a/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts +++ b/mobile/src/transport/mobile-relay-error-timer-cleanup.test.ts @@ -156,18 +156,18 @@ describe('closed relay link transport-error timer ownership', () => { expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) }) - it('retains late host-reason reporting after a rejected handshake', async () => { + it('holds a rejected handshake for its close and leaves no timer after it', async () => { const { socket, onError, onHostCloseReason } = linkFixture() socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4409 }) }) await vi.advanceTimersByTimeAsync(0) - expect(onError).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ message: 'relay_outer_4409' }) - ) + expect(onError).not.toHaveBeenCalled() socket.onerror?.() - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) socket.onclose?.({ code: 4409, reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT }) expect(onHostCloseReason).toHaveBeenCalledExactlyOnceWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) - expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'relay_outer_4409', rejectedByRelayHello: true }) + ) expect(vi.getTimerCount()).toBe(0) }) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index 7fe991a760cf..0bca568e6a9a 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { MOBILE_RELAY_PHONE_CLOSE_REASON } from '../../../src/shared/mobile-relay-close-codes' import { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayReconnectController } from './mobile-relay-reconnect-controller' +import { FakeLogicalClient } from './mobile-endpoint-supervisor-test-fakes' import { RelayDirectorHttpError } from './mobile-relay-resume-director' import { relayFailureAllowsGraceRetry } from './relay-credential-eligibility' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -379,6 +381,50 @@ describe('relay reconnect controller', () => { expect(relayFailureAllowsGraceRetry(new RelayOuterError(4408))).toBe(false) expect(relayFailureAllowsGraceRetry(new RelayOuterError(4429))).toBe(false) }) + + it('probes a retained relay once on a real background return, not on a foreground nudge', () => { + const reconnect = createController(vi.fn()) + const session = { getFailure: () => null, probeAfterBackground: vi.fn() } + const logical = new FakeLogicalClient('connected', 'relay') + reconnect.setActiveSession(session) + + reconnect.handleForeground(logical, true) + expect(session.probeAfterBackground).not.toHaveBeenCalled() + reconnect.handleForeground(logical, false) + expect(session.probeAfterBackground).toHaveBeenCalledOnce() + + // A relay the grace dropped (or never kept) is not probed. + reconnect.clear() + reconnect.handleForeground(logical, false) + expect(session.probeAfterBackground).toHaveBeenCalledOnce() + }) + + it('redials an attach timeout once quickly per failure streak', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const attachTimeout = () => + new RelayOuterError(4404, true, MOBILE_RELAY_PHONE_CLOSE_REASON.HOST_ATTACH_TIMEOUT) + + // Jittered over 1โ€“2s; this controller's fixed jitter lands mid-range. + reconnect.registerFailure(attachTimeout()) + vi.advanceTimersByTime(1_499) + expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onRetry).toHaveBeenCalledOnce() + + reconnect.registerFailure(attachTimeout()) + vi.advanceTimersByTime(9_999) + expect(onRetry).toHaveBeenCalledOnce() + vi.advanceTimersByTime(1) + expect(onRetry).toHaveBeenCalledTimes(2) + + // A stable connection ends the streak and earns a fresh quick redial. + reconnect.setActiveSession({ getFailure: () => null, probeAfterBackground: () => {} }) + vi.advanceTimersByTime(30_000) + reconnect.registerFailure(attachTimeout()) + vi.advanceTimersByTime(1_500) + expect(onRetry).toHaveBeenCalledTimes(3) + }) }) function createController( diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index e529bc87cfa7..6a73c105efb4 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -25,6 +25,7 @@ export type RelayReconnectDependencies = { } type RecoveryGate = 'external-signal' | 'fresh-credential' +type RetainableRelaySession = Pick export class RelayReconnectController { private readonly failureCount = new RelayRecoveryFailureCount(RELAY_STABLE_CONNECTION_MS) @@ -32,7 +33,7 @@ export class RelayReconnectController { private activeRelayConnectedAt: number | null = null private nextAttemptAt = 0 private timer: ReturnType | null = null - private activeSession: Pick | null = null + private activeSession: RetainableRelaySession | null = null private recoveryGate: RecoveryGate | null = null private gateReprobePending = false private gateReprobeStreak = 0 @@ -60,6 +61,8 @@ export class RelayReconnectController { handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { + // Only a relay the background grace retained is still set here. + this.activeSession?.probeAfterBackground() // Why: an app resume is a fresh signal, unlike repeated network-flap // nudges โ€” it resets the gated cadence even when it cannot lift the // credential gate, so reopening the app never waits out a 15min tick. @@ -121,8 +124,8 @@ export class RelayReconnectController { logical.suspendActiveSession() } - // Only the failure is ever read back; the narrow type keeps test doubles honest. - setActiveSession(session: Pick): void { + // Only these are ever used; the narrow type keeps test doubles honest. + setActiveSession(session: RetainableRelaySession): void { // Why: an authenticated relay is the desktop accepting this device โ€” the only // evidence that outranks a rejection streak. this.evidence.clear() @@ -276,7 +279,7 @@ export class RelayReconnectController { // only reaches this branch โ€” a director HTTP error carries no relay close code. const delay = recovery?.kind === 'retry-after-host-offline' - ? this.delays.hostOfflineDelayMs() + ? this.delays.hostOfflineDelayMs(this.failureCount.claimAttachTimeoutRedial(error)) : this.delays.transportDelayMs(failureCount, relayDirectorRetryAfterMs(error)) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts index a1c68cc9a156..c114cff4bf90 100644 --- a/mobile/src/transport/mobile-relay-retry-delays.ts +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -6,6 +6,11 @@ const RELAY_BACKOFF_CEILING_MS = 30_000 export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: an attach timeout means the desktop's control is seated, so a transient +// data-dial miss is worth one quick redial of the same cell โ€” jittered so a +// cell-wide event does not redial its phones in lockstep. +const RELAY_ATTACH_TIMEOUT_REDIAL_MIN_MS = 1_000 +const RELAY_ATTACH_TIMEOUT_REDIAL_MAX_MS = 2_000 // Why: gates must slow recovery down, never end it โ€” a relay-only phone has no // direct path to refresh credentials, so a timerless gate is a permanent outage. // The cadence escalates and jitters so a permanently revoked device is not a @@ -29,9 +34,11 @@ export class RelayRetryDelays { return Math.max(RELAY_BACKOFF_MIN_MS, floorMs, Math.floor(cap * this.jitterFraction())) } - hostOfflineDelayMs(): number { - const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS - return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + hostOfflineDelayMs(attachTimeoutRedial = false): number { + const [min, max] = attachTimeoutRedial + ? [RELAY_ATTACH_TIMEOUT_REDIAL_MIN_MS, RELAY_ATTACH_TIMEOUT_REDIAL_MAX_MS] + : [RELAY_HOST_OFFLINE_RETRY_MIN_MS, RELAY_HOST_OFFLINE_RETRY_MAX_MS] + return min + Math.floor((max - min) * this.jitterFraction()) } gateReprobeDelayMs(streak: number): number { diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index b811721e562e..c9880f267199 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -215,4 +215,49 @@ describe('mobile relay RPC session liveness', () => { expect(session.getState()).toBe('disconnected') expect(fakes.close).toHaveBeenCalledOnce() }) + + it('redials after one 4s miss when a retained relay returns from the background', async () => { + const onLog = vi.fn() + const session = await authenticateSession(onLog) + + session.probeAfterBackground() + expect(sentRequests().map(({ method }) => method)).toEqual(['status.get']) + await vi.advanceTimersByTimeAsync(3_999) + expect(session.getState()).toBe('connected') + await vi.advanceTimersByTimeAsync(1) + + expect(session.getState()).toBe('disconnected') + expect(fakes.sendText).toHaveBeenCalledOnce() + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'liveness-timeout', + detail: expect.stringMatching(/^probe-timeout; 1\/1 probes missed;/) + }) + ) + }) + + it('keeps two 4s misses for an app-resume nudge without a background stay', async () => { + const session = await authenticateSession() + + session.notifyForeground('app-resume') + await vi.advanceTimersByTimeAsync(4_000) + expect(session.getState()).toBe('connected') + await vi.advanceTimersByTimeAsync(4_000) + + expect(session.getState()).toBe('disconnected') + }) + + it('keeps a relay that answers the background-return probe', async () => { + const session = await authenticateSession() + + session.probeAfterBackground() + const probe = sentRequests()[0]! + fakes.linkOptions!.onText( + JSON.stringify({ id: probe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } }) + ) + await vi.advanceTimersByTimeAsync(60_000) + + expect(session.getState()).toBe('connected') + session.close() + }) }) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index c6e5107b913e..e56fb6a4236d 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -25,6 +25,10 @@ import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' const RELAY_PROBE_TIMEOUT_MS = 4_000 const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 +// Why: a relay kept through the background grace may have died while the app was +// suspended; one miss redials instead of two. Not shorter: the first packet after +// resume can pay radio wake-up plus a TCP retransmit. +const RELAY_RESUME_PROBE_MS = 4_000 let relayRpcSessionSequence = 0 export type MobileRelayRpcSession = RpcClient & @@ -35,6 +39,8 @@ export type MobileRelayRpcSession = RpcClient & getResumeExpiresAt(): number | null getResumeConfirmation(): DeviceResumeConfirmed | null getFailure(): Error | null + // One decisive probe for a relay retained through a real background stay. + probeAfterBackground(): void } export function connectMobileRelayRpcSession(args: { @@ -150,7 +156,9 @@ export function connectMobileRelayRpcSession(args: { getAttachDeadlineAt: () => attachDeadlineAt, getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, - getFailure: () => failure + getFailure: () => failure, + probeAfterBackground: () => + state === 'connected' && livenessWatchdog.probeNow(livenessIdentity, RELAY_RESUME_PROBE_MS) } const livenessWatchdog = new RpcSessionLivenessWatchdog({ transport: 'relay', diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 0a9cc8590c9a..70779f4a0464 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -90,6 +90,7 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 getResumeConfirmation = () => null getFailure = () => this.failure + readonly probeAfterBackground = vi.fn() } class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { diff --git a/mobile/src/transport/relay-host-reachability-verdict.test.ts b/mobile/src/transport/relay-host-reachability-verdict.test.ts index a65e682da804..b16b9518e909 100644 --- a/mobile/src/transport/relay-host-reachability-verdict.test.ts +++ b/mobile/src/transport/relay-host-reachability-verdict.test.ts @@ -113,7 +113,7 @@ describe('RelayReconnectController relay host reachability', () => { const reported: RelayHostReachability[] = [] const controller = controllerReportingTo(changesReportedTo(reported)) controller.registerFailure(hostOffline(), false) - controller.setActiveSession({ getFailure: () => null }) + controller.setActiveSession({ getFailure: () => null, probeAfterBackground: () => {} }) controller.registerFailure(hostOffline(), false) controller.registerFailure(hostOffline(), false) expect(reported).toEqual(['connecting']) @@ -124,7 +124,7 @@ describe('RelayReconnectController relay host reachability', () => { const controller = controllerReportingTo(changesReportedTo(reported)) controller.registerFailure(hostOffline(), false) controller.registerFailure(hostOffline(), false) - controller.setActiveSession({ getFailure: () => null }) + controller.setActiveSession({ getFailure: () => null, probeAfterBackground: () => {} }) expect(reported).toEqual(['connecting', 'host-offline', 'connecting']) }) 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 ce0f01844eed..8231b2d73d2b 100644 --- a/mobile/src/transport/relay-host-signed-out-verdict.test.ts +++ b/mobile/src/transport/relay-host-signed-out-verdict.test.ts @@ -72,7 +72,13 @@ describe('relay close reason on the phone', () => { }) expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) - expect(onError).toHaveBeenCalledWith(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE)) + expect(onError).toHaveBeenCalledWith( + new RelayOuterError( + MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE, + false, + RELAY_HOST_CLOSE_REASON.SIGNED_OUT + ) + ) }) // An old cell sends its constant, and every other close sends nothing. @@ -91,7 +97,7 @@ describe('relay close reason on the phone', () => { // The rejection arrives as a relay-hello AND a close, in an unordered pair. // Whichever lands first, the reason must survive. - it('still reports the reason when the hello already failed the link', async () => { + it('still reports the reason when the hello refusal lands first', async () => { const socket = new FakeSocket() const onHostCloseReason = vi.fn() linkOver(socket, onHostCloseReason, vi.fn()) @@ -173,7 +179,7 @@ describe('the sign-out the cell reported outranks a later plain 4404', () => { const controller = controllerReporting(reported) controller.assertHostReachability('signed-out') - controller.setActiveSession({ getFailure: () => null }) + controller.setActiveSession({ getFailure: () => null, probeAfterBackground: () => {} }) expect(reported).toEqual(['connecting', 'signed-out', 'connecting']) }) diff --git a/mobile/src/transport/relay-recovery-failure-count.ts b/mobile/src/transport/relay-recovery-failure-count.ts index 1bbadbcd400e..6939713fdc08 100644 --- a/mobile/src/transport/relay-recovery-failure-count.ts +++ b/mobile/src/transport/relay-recovery-failure-count.ts @@ -1,5 +1,12 @@ +import { + MOBILE_RELAY_CLOSE_CODE, + MOBILE_RELAY_PHONE_CLOSE_REASON +} from '../../../src/shared/mobile-relay-close-codes' +import { RelayOuterError } from './mobile-relay-e2ee-link' + export class RelayRecoveryFailureCount { private count = 0 + private attachTimeoutRedialSpent = false private reporter: ((count: number) => void) | null = null constructor(private readonly stableConnectionMs: number) {} @@ -14,15 +21,29 @@ export class RelayRecoveryFailureCount { } record(resetFirst: boolean): number { + if (resetFirst) { + this.attachTimeoutRedialSpent = false + } this.update((resetFirst ? 0 : this.count) + 1) return this.count } + // Why: one quick redial per streak; a desktop whose data link keeps missing + // the attach deadline falls back to the known-offline wait. + claimAttachTimeoutRedial(error: Error | null): boolean { + if (!isHostAttachTimeout(error) || this.attachTimeoutRedialSpent) { + return false + } + this.attachTimeoutRedialSpent = true + return true + } + recordAfterConnection(connectedAt: number | null, now: number): number { return this.record(connectedAt != null && now - connectedAt >= this.stableConnectionMs) } reset(): void { + this.attachTimeoutRedialSpent = false this.update(0) } @@ -34,3 +55,13 @@ export class RelayRecoveryFailureCount { this.reporter?.(count) } } + +// The cell held the dial for the desktop's data link, which never attached while +// its control looked alive. No re-resolve: the director names the seated cell. +function isHostAttachTimeout(error: Error | null): boolean { + return ( + error instanceof RelayOuterError && + error.code === MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE && + error.closeReason === MOBILE_RELAY_PHONE_CLOSE_REASON.HOST_ATTACH_TIMEOUT + ) +} diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts index aa1398e44b63..fc79154515a9 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts @@ -173,4 +173,25 @@ describe('RpcSessionLivenessWatchdog', () => { watchdog.probeNow(identity) expect(terminate).toHaveBeenCalledWith(identity) }) + + it('ends the session on the first miss of a decisive probe', async () => { + const { identity, sendProbe, terminate, watchdog } = fixture() + watchdog.probeNow(identity, 3_000) + + await vi.advanceTimersByTimeAsync(2_999) + expect(terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(terminate).toHaveBeenCalledOnce() + expect(sendProbe).toHaveBeenCalledOnce() + }) + + it('keeps a decisive probe alive when it is answered', async () => { + const { identity, terminate, watchdog } = fixture() + watchdog.probeNow(identity, 3_000) + await vi.advanceTimersByTimeAsync(1_000) + watchdog.noteAuthenticatedInbound(identity) + + await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS - 1) + expect(terminate).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index 1b2251e13731..b234b444c048 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -92,7 +92,9 @@ export class RpcSessionLivenessWatchdog { this.armIdle(identity) } - probeNow(identity: RpcSessionIdentity): void { + // decisiveTimeoutMs: one short probe whose first miss ends the session, for a + // link the OS may have killed while the app was suspended. + probeNow(identity: RpcSessionIdentity, decisiveTimeoutMs: number | null = null): void { if (this.identity !== identity || this.probing) { return } @@ -104,7 +106,7 @@ export class RpcSessionLivenessWatchdog { return } this.lastVoluntaryProbeAt = now - this.startProbe(identity) + this.startProbe(identity, decisiveTimeoutMs) } stop(identity: RpcSessionIdentity): void { @@ -138,7 +140,7 @@ export class RpcSessionLivenessWatchdog { }, delayMs) } - private startProbe(identity: RpcSessionIdentity): void { + private startProbe(identity: RpcSessionIdentity, decisiveTimeoutMs: number | null = null): void { if (this.identity !== identity) { return } @@ -155,27 +157,36 @@ export class RpcSessionLivenessWatchdog { this.terminateCurrent(identity, 'probe-send-failed') return } - this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs) + this.timer = this.setTimer( + () => this.handleProbeTimeout(identity, sentAt, decisiveTimeoutMs), + decisiveTimeoutMs ?? this.probeTimeoutMs + ) } - private handleProbeTimeout(identity: RpcSessionIdentity, sentAt: number): void { + private handleProbeTimeout( + identity: RpcSessionIdentity, + sentAt: number, + decisiveTimeoutMs: number | null + ): void { this.timer = null if (this.identity !== identity) { return } + const timeoutMs = decisiveTimeoutMs ?? this.probeTimeoutMs const elapsedMs = this.now() - sentAt - if (elapsedMs < 0 || elapsedMs > this.probeTimeoutMs * 1.5) { + if (elapsedMs < 0 || elapsedMs > timeoutMs * 1.5) { console.log('[net] activity-probe unfair window skipped', { transport: this.options.transport, elapsedMs, - timeoutMs: this.probeTimeoutMs + timeoutMs }) - this.startProbe(identity) + this.startProbe(identity, decisiveTimeoutMs) return } this.missedProbes += 1 - if (this.missedProbes >= this.missedProbeLimit) { - this.terminateCurrent(identity, 'probe-timeout') + const missedProbeLimit = decisiveTimeoutMs === null ? this.missedProbeLimit : 1 + if (this.missedProbes >= missedProbeLimit) { + this.terminateCurrent(identity, 'probe-timeout', missedProbeLimit) return } console.log('[net] activity-probe timeout tolerated', { @@ -188,7 +199,8 @@ export class RpcSessionLivenessWatchdog { private terminateCurrent( identity: RpcSessionIdentity, - reason: LivenessTimeoutEvidence['reason'] + reason: LivenessTimeoutEvidence['reason'], + missedProbeLimit = this.missedProbeLimit ): void { if (this.identity !== identity) { return @@ -199,13 +211,13 @@ export class RpcSessionLivenessWatchdog { console.log('[net] activity-probe TIMEOUT โ€” forcing reconnect', { transport: this.options.transport, missedProbes: this.missedProbes, - missedProbeLimit: this.missedProbeLimit + missedProbeLimit }) this.options.onTimeout?.({ transport: this.options.transport, reason, missedProbes: this.missedProbes, - missedProbeLimit: this.missedProbeLimit, + missedProbeLimit, lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt) }) this.options.terminate(identity) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index e69767b6ed7d..75b9c780387f 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -86,6 +86,9 @@ export type FilterState = WorkspaceRowFilters // repoIcon stays unread wire data until drawn (readMobileRepoIcon). export type SectionRepo = ProjectSectionRepo & { badgeColor?: string; repoIcon?: unknown } +/** A workspace the host is still creating; the feed names only its project, null under its own header. */ +export type SectionPendingCreation = { key: string; repoName: string | null } + export type Section = { key: string title: string @@ -94,5 +97,7 @@ export type Section = { repo?: SectionRepo /** The header's own count from a feed-built list, which a collapsed section still shows. */ count?: number + /** Drawn under the header, above its rows, as the desktop places them. */ + pendingCreations?: SectionPendingCreation[] data: Worktree[] } diff --git a/package.json b/package.json index 2b521ba852aa..9790bf028f5b 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:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:deprecated-runtime-owner-ratchet && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && 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 && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports", + "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:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && 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 && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports", "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", @@ -38,9 +38,9 @@ "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", - "check:deprecated-runtime-owner-ratchet": "node config/scripts/check-deprecated-runtime-owner-ratchet.mjs", "check:path-literal-ratchet": "node config/scripts/check-path-literal-ratchet.mjs", "check:legacy-relay-fence-ratchet": "node config/scripts/check-legacy-relay-fence-ratchet.mjs", + "check:unproven-pty-retirement-ratchet": "node config/scripts/check-unproven-pty-retirement-ratchet.mjs", "check:worktree-id-composer-ratchet": "node config/scripts/check-worktree-id-composer-ratchet.mjs", "check:e2e-typecheck-ratchet": "node config/scripts/check-e2e-typecheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", @@ -255,7 +255,7 @@ "@tiptap/starter-kit": "3.31.3", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", - "@types/node": "^25.6.0", + "@types/node": "^24.19.2", "@types/proper-lockfile": "^4.1.4", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b36756f4dbbb..4ec292f6cb67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,7 +286,7 @@ importers: version: 10.0.0(@dnd-kit/core@6.3.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(@types/react@19.2.18)(react@19.2.8) '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.9.5) + version: 2.0.0(@types/node@24.19.2) '@electron/rebuild': specifier: ^4.2.1 version: 4.2.1(supports-color@7.2.0) @@ -316,13 +316,13 @@ importers: version: 0.0.26 '@tailwindcss/vite': specifier: ^4.3.3 - version: 4.3.3(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 4.3.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@tanstack/react-virtual': specifier: ^3.14.13 version: 3.14.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: 6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@20.14.5)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8) @@ -390,8 +390,8 @@ importers: specifier: ^4.0.4 version: 4.0.4 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^24.19.2 + version: 24.19.2 '@types/proper-lockfile': specifier: ^4.1.4 version: 4.1.4 @@ -412,7 +412,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 6.1.2(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vscode/ripgrep-universal': specifier: 1.18.0 version: 1.18.0 @@ -469,7 +469,7 @@ importers: version: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@7.2.0) electron-vite: specifier: ^6.0.0 - version: 6.0.0(@swc/core@1.16.2)(electron@43.7.8(supports-color@7.2.0))(supports-color@7.2.0)(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 6.0.0(@swc/core@1.16.2)(electron@43.7.8(supports-color@7.2.0))(supports-color@7.2.0)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) emoji-picker-react: specifier: ^4.22.3 version: 4.22.3(@types/react@19.2.18)(react@19.2.8) @@ -493,7 +493,7 @@ importers: version: 9.1.7 i18next-cli: specifier: 1.74.2 - version: 1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@25.9.5)(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + version: 1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@24.19.2)(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@7.0.2) katex: specifier: 0.18.10 version: 0.18.10 @@ -628,10 +628,10 @@ importers: version: 11.0.5 vite: specifier: ^8.3.4 - version: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + version: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 5.0.5 - version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@20.14.5)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -4154,8 +4154,8 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@24.12.4': - resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} @@ -8056,9 +8056,6 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -8836,9 +8833,9 @@ snapshots: '@electron-internal/extract-zip@1.0.4': {} - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@24.19.2)': dependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@electron-toolkit/utils@4.0.0(electron@43.7.8(supports-color@7.2.0))': dependencies: @@ -9115,14 +9112,14 @@ snapshots: '@inquirer/ansi@2.0.8': {} - '@inquirer/checkbox@5.2.5(@types/node@25.9.5)': + '@inquirer/checkbox@5.2.5(@types/node@24.19.2)': dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@inquirer/confirm@6.3.2(@types/node@18.19.130)': dependencies: @@ -9132,12 +9129,12 @@ snapshots: '@types/node': 18.19.130 optional: true - '@inquirer/confirm@6.3.2(@types/node@25.9.5)': + '@inquirer/confirm@6.3.2(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@inquirer/core@12.0.3(@types/node@18.19.130)': dependencies: @@ -9152,111 +9149,111 @@ snapshots: '@types/node': 18.19.130 optional: true - '@inquirer/core@12.0.3(@types/node@25.9.5)': + '@inquirer/core@12.0.3(@types/node@24.19.2)': dependencies: '@inquirer/ansi': 2.0.8 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@24.19.2) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/editor@5.3.3(@types/node@25.9.5)': + '@inquirer/editor@5.3.3(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/external-editor': 3.0.5(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/external-editor': 3.0.5(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/expand@5.1.5(@types/node@25.9.5)': + '@inquirer/expand@5.1.5(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/external-editor@3.0.5(@types/node@25.9.5)': + '@inquirer/external-editor@3.0.5(@types/node@24.19.2)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.6(@types/node@25.9.5)': + '@inquirer/input@5.1.6(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/number@4.2.3(@types/node@25.9.5)': + '@inquirer/number@4.2.3(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/password@5.2.2(@types/node@25.9.5)': + '@inquirer/password@5.2.2(@types/node@24.19.2)': dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/prompts@8.7.2(@types/node@25.9.5)': + '@inquirer/prompts@8.7.2(@types/node@24.19.2)': dependencies: - '@inquirer/checkbox': 5.2.5(@types/node@25.9.5) - '@inquirer/confirm': 6.3.2(@types/node@25.9.5) - '@inquirer/editor': 5.3.3(@types/node@25.9.5) - '@inquirer/expand': 5.1.5(@types/node@25.9.5) - '@inquirer/input': 5.1.6(@types/node@25.9.5) - '@inquirer/number': 4.2.3(@types/node@25.9.5) - '@inquirer/password': 5.2.2(@types/node@25.9.5) - '@inquirer/rawlist': 5.3.5(@types/node@25.9.5) - '@inquirer/search': 4.3.3(@types/node@25.9.5) - '@inquirer/select': 5.2.5(@types/node@25.9.5) + '@inquirer/checkbox': 5.2.5(@types/node@24.19.2) + '@inquirer/confirm': 6.3.2(@types/node@24.19.2) + '@inquirer/editor': 5.3.3(@types/node@24.19.2) + '@inquirer/expand': 5.1.5(@types/node@24.19.2) + '@inquirer/input': 5.1.6(@types/node@24.19.2) + '@inquirer/number': 4.2.3(@types/node@24.19.2) + '@inquirer/password': 5.2.2(@types/node@24.19.2) + '@inquirer/rawlist': 5.3.5(@types/node@24.19.2) + '@inquirer/search': 4.3.3(@types/node@24.19.2) + '@inquirer/select': 5.2.5(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/rawlist@5.3.5(@types/node@25.9.5)': + '@inquirer/rawlist@5.3.5(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/search@4.3.3(@types/node@25.9.5)': + '@inquirer/search@4.3.3(@types/node@24.19.2)': dependencies: - '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 - '@inquirer/select@5.2.5(@types/node@25.9.5)': + '@inquirer/select@5.2.5(@types/node@24.19.2)': dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/type': 4.1.1(@types/node@24.19.2) optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@inquirer/type@4.1.1(@types/node@18.19.130)': optionalDependencies: '@types/node': 18.19.130 optional: true - '@inquirer/type@4.1.1(@types/node@25.9.5)': + '@inquirer/type@4.1.1(@types/node@24.19.2)': optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -10963,12 +10960,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@tailwindcss/vite@4.3.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) '@tanstack/react-virtual@3.14.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)': dependencies: @@ -10992,7 +10989,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + '@testing-library/jest-dom@6.9.1(vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@20.14.5)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': dependencies: '@adobe/css-tools': 4.5.0 '@types/aria-query': 5.0.4 @@ -11002,7 +10999,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@20.14.5)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)': dependencies: @@ -11444,9 +11441,9 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@24.12.4': + '@types/node@24.19.2': dependencies: - undici-types: 7.16.0 + undici-types: 7.24.6 '@types/node@25.9.5': dependencies: @@ -11476,7 +11473,7 @@ snapshots: '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.9.5 + '@types/node': 18.19.130 optional: true '@types/ssh2@1.15.6': @@ -11574,10 +11571,10 @@ snapshots: transitivePeerDependencies: - '@types/d3-selection' - '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) '@vitest/mocker@5.0.5(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: @@ -11589,15 +11586,15 @@ snapshots: msw: 2.14.3(@types/node@18.19.130)(typescript@7.0.2) vite: 8.3.4(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) - '@vitest/mocker@5.0.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': + '@vitest/mocker@5.0.5(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) - vite: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + msw: 2.14.3(@types/node@24.19.2)(typescript@7.0.2) + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) '@vitest/spy@5.0.5': {} @@ -12522,7 +12519,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@6.0.0(@swc/core@1.16.2)(electron@43.7.8(supports-color@7.2.0))(supports-color@7.2.0)(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + electron-vite@6.0.0(@swc/core@1.16.2)(electron@43.7.8(supports-color@7.2.0))(supports-color@7.2.0)(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) @@ -12531,7 +12528,7 @@ snapshots: magic-string: 1.4.2 picocolors: 1.1.1 rolldown: 1.2.14 - vite: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) optionalDependencies: '@swc/core': 1.16.2 transitivePeerDependencies: @@ -12555,7 +12552,7 @@ snapshots: dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0(supports-color@7.2.0) - '@types/node': 24.12.4 + '@types/node': 24.19.2 transitivePeerDependencies: - supports-color @@ -13347,7 +13344,7 @@ snapshots: husky@9.1.7: {} - i18next-cli@1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@25.9.5)(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + i18next-cli@1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@24.19.2)(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@croct/json5-parser': 0.2.2 '@swc/core': 1.16.2 @@ -13357,7 +13354,7 @@ snapshots: glob: 13.0.6 i18next: 26.4.2(typescript@7.0.2) i18next-resources-for-ts: 2.1.1 - inquirer: 14.2.2(@types/node@25.9.5) + inquirer: 14.2.2(@types/node@24.19.2) jiti: 2.7.0 jsonc-parser: 3.3.1 magic-string: 1.4.2 @@ -13422,16 +13419,16 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@14.2.2(@types/node@25.9.5): + inquirer@14.2.2(@types/node@24.19.2): dependencies: '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@25.9.5) - '@inquirer/prompts': 8.7.2(@types/node@25.9.5) - '@inquirer/type': 4.1.1(@types/node@25.9.5) + '@inquirer/core': 12.0.3(@types/node@24.19.2) + '@inquirer/prompts': 8.7.2(@types/node@24.19.2) + '@inquirer/type': 4.1.1(@types/node@24.19.2) mute-stream: 3.0.0 run-async: 4.0.6 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 inspect-webkit@0.0.5(typescript@7.0.2): dependencies: @@ -14371,9 +14368,9 @@ snapshots: - '@types/node' optional: true - msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2): + msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2): dependencies: - '@inquirer/confirm': 6.3.2(@types/node@25.9.5) + '@inquirer/confirm': 6.3.2(@types/node@24.19.2) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -15888,8 +15885,6 @@ snapshots: undici-types@5.26.5: {} - undici-types@7.16.0: {} - undici-types@7.24.6: {} undici@6.29.0: {} @@ -16032,7 +16027,7 @@ snapshots: transitivePeerDependencies: - '@types/picomatch' - vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): + vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -16040,7 +16035,7 @@ snapshots: rolldown: 1.2.14 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 24.19.2 esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 @@ -16074,10 +16069,10 @@ snapshots: - '@types/picomatch' - msw - vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@20.14.5)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + '@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -16088,11 +16083,11 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 25.9.5 + '@types/node': 24.19.2 happy-dom: 20.14.5 transitivePeerDependencies: - '@types/msw' diff --git a/src/cli/agent-launch-request.ts b/src/cli/agent-launch-request.ts index a2d9024bbd6e..d3c6c8838a28 100644 --- a/src/cli/agent-launch-request.ts +++ b/src/cli/agent-launch-request.ts @@ -11,6 +11,7 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY, + AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY } from '../shared/agent-launch-runtime-capability' import { createStructuredAgentSessionOperationId } from '../shared/structured-agent-session-mutation' @@ -122,6 +123,16 @@ export async function callAgentLaunch( 'This Orca host cannot honor CLI launch focus or background behavior. Update Orca on the execution host.' ) } + if ( + launch.agent === 'omp' && + launch.sessionOptions?.effort !== undefined && + !capabilities.includes(AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca host cannot preserve OMP launch thinking. Update Orca on the execution host, or omit --effort.' + ) + } const operationId = replaySupported ? (launch.operationId ?? createStructuredAgentSessionOperationId(randomUUID)) : undefined diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts index a7058b17134b..e76dcf4d71eb 100644 --- a/src/cli/cli-error.ts +++ b/src/cli/cli-error.ts @@ -5,6 +5,7 @@ import { } from '../shared/automation-owner-conflict' import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery' import { worktreeSelectorRecovery } from './worktree-selector-recovery' +import { selectorAmbiguityRecovery } from './selector-ambiguity-recovery' import type { RuntimeRpcFailure } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types' @@ -12,6 +13,8 @@ export type CliErrorContext = { commandPath?: readonly string[] /** The `--worktree` value this invocation sent; the runtime's error never echoes it. */ worktreeSelector?: string + /** The `--repo` value this invocation sent, for the same reason. */ + repoSelector?: string /** True when --environment, --pairing-code, --host runtime:, or their env vars chose a server. */ remoteServerSelected?: boolean } @@ -28,6 +31,13 @@ function contextRecovery( if (code === 'selector_not_found' && context.worktreeSelector) { return worktreeSelectorRecovery(context.worktreeSelector) } + if (code === 'selector_ambiguous') { + return selectorAmbiguityRecovery({ + repo: context.repoSelector, + worktree: context.worktreeSelector, + remoteServerSelected: context.remoteServerSelected + }) + } // Why: a paired server's handle sent to this machine reads as a dead terminal, not a missing flag. if ( code === 'terminal_handle_stale' && diff --git a/src/cli/format-recovery.test.ts b/src/cli/format-recovery.test.ts index bf04ff08baaa..055d0a23cd3b 100644 --- a/src/cli/format-recovery.test.ts +++ b/src/cli/format-recovery.test.ts @@ -12,6 +12,73 @@ function selectorNotFound(): RuntimeRpcFailureError { }) } +function selectorAmbiguous(): RuntimeRpcFailureError { + return new RuntimeRpcFailureError({ + id: 'req_selector', + ok: false, + error: { code: 'selector_ambiguous', message: 'selector_ambiguous' }, + _meta: { runtimeId: 'runtime_local' } + }) +} + +describe('ambiguous selector recovery', () => { + it('names the ambiguous --repo value and the exact forms for twin projects', () => { + const output = formatCliError(selectorAmbiguous(), { + commandPath: ['worktree', 'create'], + repoSelector: 'app' + }) + + expect(output).toContain('More than one project matches --repo "app"') + expect(output).toContain('id: or path:') + expect(output).toContain('orca repo list --json') + }) + + it('carries the ambiguous --worktree value into the --json failure envelope', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + reportCliError(selectorAmbiguous(), true, { + commandPath: ['terminal', 'create'], + worktreeSelector: 'name:feature' + }) + + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + error: { + code: 'selector_ambiguous', + data: { + ambiguousSelectors: [{ flag: '--worktree', selector: 'name:feature' }], + nextSteps: expect.arrayContaining([expect.stringContaining('identity:')]) + } + } + }) + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + error: { + data: { + nextSteps: expect.arrayContaining([ + 'List the exact values with `orca worktree list --json`.' + ]) + } + } + }) + log.mockRestore() + }) + + it('keeps the listing on the paired server the command chose', () => { + const output = formatCliError(selectorAmbiguous(), { + commandPath: ['repo', 'show'], + repoSelector: 'app', + remoteServerSelected: true + }) + + expect(output).toContain('`orca repo list --json`, passing the same server flag') + }) + + it('stays silent when no selector flag was passed', () => { + expect(formatCliError(selectorAmbiguous(), { commandPath: ['repo', 'list'] })).toBe( + 'selector_ambiguous' + ) + }) +}) + describe('worktree selector recovery', () => { it('names the offending value and the valid forms on a bare repo id', () => { const output = formatCliError(selectorNotFound(), { diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 24d1edf75a4d..67c94ea4f4ba 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -4,15 +4,6 @@ const callMock = vi.fn() const originalExitCode = process.exitCode const originalCliCommand = process.env.ORCA_CLI_COMMAND -type RecoveryWorkerStartResult = { - taskId: string - dispatchId: string - state: string - effects: unknown[] - residualResources: unknown[] - nextCommands: string[] -} - vi.mock('../format', () => ({ printResult: vi.fn() })) vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) @@ -144,6 +135,21 @@ describe('orchestration worker-start CLI contract', () => { ) }) + it('refuses OMP thinking when an old peer cannot honor launch preferences', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'omp'], + ['model', 'custom/model'], + ['effort', 'max'] + ]) + ) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) + expect(callMock).toHaveBeenCalledTimes(1) + }) + it('forwards --spec without a task for atomic creation', async () => { callMock.mockResolvedValue({ result: { runId: 'run_1', taskId: 'task_new', dispatchId: 'ctx_1', state: 'ready' } @@ -200,53 +206,6 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBe(1) }) - it.each([ - ['JSON', 'orca-dev', true], - ['plain', 'orca-ide', false] - ] as const)( - 'renders %s recovery commands through the resolved %s executable', - async (_format, executable, json) => { - process.env.ORCA_CLI_COMMAND = executable - callMock.mockResolvedValue({ - result: { - taskId: 'task_1', - dispatchId: 'ctx_unknown', - state: 'outcome_unknown', - effects: [], - residualResources: [], - nextCommands: [ - 'orca orchestration worker-show --dispatch ctx_unknown --json', - 'orca orchestration worker-abandon --dispatch ctx_unknown --json' - ] - } - }) - - await invokeWorkerStart( - new Map([ - ['task', 'task_1'], - ['agent', 'codex'], - ['from', 'term_coord'] - ]), - json - ) - - const [response, , formatter] = vi.mocked(printResult).mock.calls[0] as [ - { result: RecoveryWorkerStartResult }, - boolean, - (result: RecoveryWorkerStartResult) => string - ] - expect(response.result.nextCommands).toEqual([ - `${executable} orchestration worker-show --dispatch ctx_unknown --json`, - `${executable} orchestration worker-abandon --dispatch ctx_unknown --json` - ]) - if (!json) { - expect(formatter(response.result)).toContain( - `Next command: ${executable} orchestration worker-show --dispatch ctx_unknown --json` - ) - } - } - ) - it('prints the host error for a refused worker start', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration-worker-recovery-output.test.ts b/src/cli/handlers/orchestration-worker-recovery-output.test.ts new file mode 100644 index 000000000000..9d02f3db05f4 --- /dev/null +++ b/src/cli/handlers/orchestration-worker-recovery-output.test.ts @@ -0,0 +1,73 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint' +import { RuntimeClient } from '../runtime-client' +import { ORCHESTRATION_WORKER_LAUNCH_HANDLER } from './orchestration/worker-launch-handler' + +const originalExitCode = process.exitCode + +afterEach(() => { + process.exitCode = originalExitCode + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +it.each([ + { executable: 'orca-dev', json: true, state: 'failed' }, + { executable: 'orca-dev', json: false, state: 'outcome_unknown' }, + { executable: 'orca-ide', json: true, state: 'outcome_unknown' }, + { executable: 'orca-ide', json: false, state: 'failed' } +])( + 'renders runnable $executable recovery commands for $state (json=$json)', + async ({ executable, json, state }) => { + vi.stubEnv('ORCA_CLI_COMMAND', executable) + const client = new RuntimeClient('/verification/worker-recovery', 60_000, null, null) + vi.spyOn(client, 'call').mockResolvedValue({ + id: 'worker-recovery', + ok: true, + _meta: { runtimeId: 'runtime_recovery' }, + result: { + runId: 'run_recovery', + taskId: 'task_recovery', + dispatchId: 'ctx_recovery', + state, + effects: [], + residualResources: [], + nextCommands: [ + 'orca orchestration worker-show --dispatch ctx_recovery --json', + 'orca orchestration worker-abandon --dispatch ctx_recovery --json' + ] + } + }) + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await ORCHESTRATION_WORKER_LAUNCH_HANDLER['orchestration worker-start']({ + flags: new Map([ + ['task', 'task_recovery'], + ['agent', 'codex'], + ['from', 'term_coordinator'] + ]), + client, + cwd: '/verification/worker-recovery', + json + }) + + const commands = [ + [executable, 'orchestration', 'worker-show', '--dispatch', 'ctx_recovery', '--json'], + [executable, 'orchestration', 'worker-abandon', '--dispatch', 'ctx_recovery', '--json'] + ] + const printed = output.mock.calls.map(([line]) => String(line)).join('\n') + const renderedCommands = json + ? z + .object({ result: z.object({ nextCommands: z.array(z.string()) }) }) + .parse(JSON.parse(printed)) + .result.nextCommands.map(tokenizeCommandLine) + : printed + .split('\n') + .map(tokenizeCommandLine) + .map((tokens) => tokens.slice(tokens.indexOf(executable))) + .filter((tokens) => tokens[0] === executable) + expect(renderedCommands).toEqual(commands) + expect(process.exitCode).toBe(1) + } +) diff --git a/src/cli/handlers/project.ts b/src/cli/handlers/project.ts index bacafc006d45..cb155299d9aa 100644 --- a/src/cli/handlers/project.ts +++ b/src/cli/handlers/project.ts @@ -12,6 +12,8 @@ import type { } from '../../shared/project-types' import { getSshTargetIdForExecutionHost, type ExecutionHostId } from '../../shared/execution-host' import type { RepoKind } from '../../shared/repo-types' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY } from '../../shared/runtime-capability-constants' import type { CommandHandler, HandlerContext } from '../dispatch' import { formatProjectHostSetupCreateResult, @@ -92,6 +94,26 @@ async function getRequiredHostId( return resolved.id } +// Why: a project on two hosts has two setups with the same id; `--host` picks one. An older +// host strips the field and acts on the first twin, so refuse rather than let it guess. +async function getOptionalSetupHostId( + flags: Map, + client: HandlerContext['client'] +): Promise { + if (!flags.has('host')) { + return undefined + } + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca server cannot pick a project setup by host yet. Update Orca on the server and try again.' + ) + } + // Unresolved, like the setup paths: a removed SSH target's stale setup must stay deletable. + return await getRequiredHostId(flags, client) +} + function getOptionalRepoKind(flags: Map): RepoKind | undefined { const kind = getOptionalStringFlag(flags, 'kind') if (kind === undefined) { @@ -199,6 +221,7 @@ export const PROJECT_HANDLERS: Record = { const path = getOptionalStringFlag(flags, 'path') const args: ProjectHostSetupUpdateArgs = { setupId: getRequiredStringFlag(flags, 'setup'), + hostId: await getOptionalSetupHostId(flags, client), updates: { displayName: getOptionalStringFlag(flags, 'display-name'), path: @@ -225,6 +248,7 @@ export const PROJECT_HANDLERS: Record = { 'projectHostSetup.delete', { setupId: getRequiredStringFlag(flags, 'setup'), + hostId: await getOptionalSetupHostId(flags, client), force: flags.get('force') === true } ) diff --git a/src/cli/host-selector-alternatives.ts b/src/cli/host-selector-alternatives.ts index 1199d9162a2a..f9e785c5b82e 100644 --- a/src/cli/host-selector-alternatives.ts +++ b/src/cli/host-selector-alternatives.ts @@ -1,4 +1,5 @@ import type { RuntimeClient } from './runtime-client' +import { normalizeTypedName, stripWrappingQuotes } from '../shared/typed-name-normalization' export type SshTargetSummary = { id: string @@ -37,16 +38,7 @@ export function findSshTargetByName( // Why: cmd.exe passes single quotes through literally, so `--host 'my box'` from a POSIX habit // arrives quoted; a host or project name never legitimately starts and ends with the same quote. -export function stripWrappingQuotes(value: string): string { - const quote = value[0] - return value.length >= 2 && (quote === "'" || quote === '"') && value.at(-1) === quote - ? value.slice(1, -1) - : value -} - -export function normalizeTypedName(value: string): string { - return stripWrappingQuotes(value.trim()).trim().toLowerCase() -} +export { normalizeTypedName, stripWrappingQuotes } function matchesByLabel(targets: readonly SshTargetSummary[], name: string): SshTargetSummary[] { const wanted = normalizeTypedName(name) diff --git a/src/cli/index-agent-launch.test.ts b/src/cli/index-agent-launch.test.ts index 37a090cb41b5..3f63e3542254 100644 --- a/src/cli/index-agent-launch.test.ts +++ b/src/cli/index-agent-launch.test.ts @@ -45,6 +45,10 @@ import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' import { agentLaunchFixture, agentLaunchHostFixture } from './agent-launch-test-fixtures' +import { + AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITIES +} from '../shared/agent-launch-runtime-capability' const WORKTREE_ID = 'repo-1::/tmp/repo' @@ -114,6 +118,35 @@ describe('orca cli agent launch', () => { expect(JSON.parse(String(logSpy.mock.calls[0]?.[0])).result.receipt.reason).toBe('user_default') }) + it.each(['off', 'auto', 'high'])( + 'refuses OMP thinking %s before an older host can silently drop it', + async (effort) => { + queueFixtures( + callMock, + okFixture('req_status', { + capabilities: AGENT_LAUNCH_RUNTIME_CAPABILITIES.filter( + (capability) => capability !== AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY + ) + }) + ) + const output = await runRefused([ + 'agent', + 'launch', + '--agent', + 'omp', + '--model', + 'anthropic/claude-opus-5-5', + '--effort', + effort, + '--worktree', + `id:${WORKTREE_ID}`, + '--json' + ]) + expect(output).toContain('cannot preserve OMP launch thinking') + expect(launchCalls()).toEqual([]) + } + ) + it('switches to the new tab only with --focus', async () => { queueFixtures(callMock, agentLaunchHostFixture(), agentLaunchFixture(WORKTREE_ID)) vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/index-project-setup.test.ts b/src/cli/index-project-setup.test.ts index dfd80df8bb71..f1bef94cdce7 100644 --- a/src/cli/index-project-setup.test.ts +++ b/src/cli/index-project-setup.test.ts @@ -43,6 +43,7 @@ vi.mock('child_process', async () => { import { main } from './index' import { okFixture, queueFixtures } from './test-fixtures' +import { PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY } from '../shared/runtime-capability-constants' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' const ORCA_PROJECT = { @@ -791,11 +792,14 @@ describe('orca cli worktree awareness', () => { }) it.each([ - { flags: [], force: false }, - { flags: ['--force'], force: true } - ])('deletes project host setup metadata with force=$force', async ({ flags, force }) => { + { flags: [], force: false, hostId: undefined }, + { flags: ['--force'], force: true, hostId: undefined }, + // Why: a project on two hosts has two setups with one id; --host names the twin. + { flags: ['--host', 'local'], force: false, hostId: 'local' } + ])('deletes project host setup metadata with $flags', async ({ flags, force, hostId }) => { queueFixtures( callMock, + ...(hostId ? [hostTargetStatusFixture(true)] : []), okFixture('req_project_setup_delete', { result: { project: { @@ -827,7 +831,26 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('projectHostSetup.delete', { setupId: 'setup-gpu', + hostId, force }) }) + + it('refuses --host on a server that would ignore it and act on the first same-id twin', async () => { + queueFixtures(callMock, hostTargetStatusFixture(false)) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['project', 'setup-delete', '--setup', 'dup', '--host', 'local', '--force', '--json'], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalledWith('projectHostSetup.delete', expect.anything()) + }) }) + +function hostTargetStatusFixture(supported: boolean) { + return okFixture('req_status', { + capabilities: supported ? [PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY] : [] + }) +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 8b2156f1e1f5..932786d24e18 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -191,10 +191,12 @@ export async function main( }) } catch (error) { const worktreeSelector = parsed.flags.get('worktree') + const repoSelector = parsed.flags.get('repo') const hostFlag = parsed.flags.get('host') reportCliError(error, json, { commandPath: parsed.commandPath, ...(typeof worktreeSelector === 'string' ? { worktreeSelector } : {}), + ...(typeof repoSelector === 'string' ? { repoSelector } : {}), remoteServerSelected: parsed.flags.has('environment') || parsed.flags.has('pairing-code') || diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts index 9559f0a24ddd..4749dc73a423 100644 --- a/src/cli/root-help-text-secondary.ts +++ b/src/cli/root-help-text-secondary.ts @@ -74,8 +74,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [ ' orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', - ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', - ' orca project setup-delete --setup [--force] [--json]', + ' orca project setup-update --setup [--host ] [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-delete --setup [--host ] [--force] [--json]', ' orca repo list [--json]', ' orca repo add --path [--json]', ' orca repo show --repo [--json]', diff --git a/src/cli/runtime/websocket-transport-error.test.ts b/src/cli/runtime/websocket-transport-error.test.ts index ab3df967f03e..907dca4b35c8 100644 --- a/src/cli/runtime/websocket-transport-error.test.ts +++ b/src/cli/runtime/websocket-transport-error.test.ts @@ -10,7 +10,6 @@ const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } ) vi.mock('../../shared/remote-runtime-client', () => ({ - RemoteRuntimeClientError, sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight })) diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index a35db2cbf164..bfdf1fc9cea0 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -1,7 +1,7 @@ import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { - RemoteRuntimeClientError, sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' diff --git a/src/cli/selector-ambiguity-recovery.ts b/src/cli/selector-ambiguity-recovery.ts new file mode 100644 index 000000000000..113b585142f3 --- /dev/null +++ b/src/cli/selector-ambiguity-recovery.ts @@ -0,0 +1,57 @@ +// Why: the runtime answers a selector that matches several rows (two projects both named `app`) +// with a bare `selector_ambiguous`, naming neither the value nor a way out. Only the CLI still +// knows which flag the caller typed, so it says which selector was ambiguous and what is exact. + +export type SelectorAmbiguityRecovery = { + ambiguousSelectors: { flag: string; selector: string }[] + nextSteps: readonly string[] +} + +const EXACT_FORMS = { + repo: { + // Why the --project route: a local and an SSH project can share both repo id and path. + forms: + 'id: or path:; if two hosts share that id or path, use --project --host where the command takes them', + list: 'orca repo list --json', + noun: 'project' + }, + worktree: { + // Why identity first: a local and an SSH checkout can share both repo id and path. + forms: + 'identity: (host-qualified), or id::: when only one host has that checkout', + list: 'orca worktree list --json', + noun: 'workspace' + } +} as const + +export function selectorAmbiguityRecovery(selectors: { + repo?: string + worktree?: string + /** The command chose a paired server, so the listing must ask that server, not this machine. */ + remoteServerSelected?: boolean +}): SelectorAmbiguityRecovery | undefined { + const entries = (['worktree', 'repo'] as const).flatMap((flag) => { + const selector = selectors[flag] + return selector ? [{ flag, selector }] : [] + }) + if (entries.length === 0) { + return undefined + } + return { + ambiguousSelectors: entries.map(({ flag, selector }) => ({ flag: `--${flag}`, selector })), + nextSteps: entries.flatMap(({ flag, selector }) => { + const exact = EXACT_FORMS[flag] + // Both flags given: the runtime does not say which one matched several rows. + const matched = + entries.length > 1 + ? `--${flag} "${selector}" may match more than one ${exact.noun}.` + : `More than one ${exact.noun} matches --${flag} "${selector}".` + return [ + `${matched} Pass an exact selector: ${exact.forms}.`, + selectors.remoteServerSelected + ? `List the exact values with \`${exact.list}\`, passing the same server flag (--environment, --pairing-code or --host runtime:) as this command.` + : `List the exact values with \`${exact.list}\`.` + ] + }) + } +} diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 9123009f9de0..59e7dd4da847 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -36,7 +36,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.', - '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', + '--model supports Claude, Codex, Cursor, Antigravity, Muse, and OMP opaque provider model ids; --effort requires --model. For OMP, --effort accepts off|minimal|low|medium|high|xhigh|max|auto and maps to its native thinking control; OMP may clamp the value to its model capabilities. Neither flag can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index d1e928f69598..f5e8cf3f492c 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -90,10 +90,11 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ path: ['project', 'setup-update'], summary: 'Update project host setup metadata', usage: - 'orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + 'orca project setup-update --setup [--host ] [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'setup', + 'host', 'display-name', 'path', 'worktree-base-path', @@ -104,6 +105,7 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Repo-backed setups mirror safe fields onto the repo record.', + '--host picks the setup when the project is set up on more than one host under the same setup id.', 'Path and availability state changes are only supported for independent setup records.' ], examples: [ @@ -115,8 +117,8 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ path: ['project', 'setup-delete'], destructive: true, summary: 'Remove a project host setup', - usage: 'orca project setup-delete --setup [--force] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'setup', 'force'], + usage: 'orca project setup-delete --setup [--host ] [--force] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'setup', 'host', 'force'], notes: [ 'Independent setups are removed directly.', 'Repo-backed setups remove the registered repo compatibility record.', diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index c3aa3f51f006..5b2a3c2f9f9a 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -1,5 +1,8 @@ import { BoundedMap } from '../../shared/bounded-map' -import { backgroundTaskJournalBody } from '../../shared/native-chat-background-task-row' +import { + backgroundTaskJournalBody, + isSettledBackgroundTaskState +} from '../../shared/native-chat-background-task-row' import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' import { boundInlineText, @@ -11,11 +14,12 @@ import type { } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' -/** Partial provider snapshots only; the assembler owns settlement and durable placement. A task - * the bound evicted (128 others updated since) loses its earlier label, parent tool and output - * file on its next update; its row keeps the turn it was placed in. */ +/** Partial provider snapshots; the assembler owns settlement and durable placement. A running + * task's snapshot lives until it settles. Settled ones are history, bounded, so a later ending + * (after a Stop found it gone) keeps its label; one evicted keeps only its row's turn. */ export class AcpBackgroundTaskTimeline { - private readonly snapshots = new BoundedMap({ + private readonly running = new Map() + private readonly settled = new BoundedMap({ maxEntries: 128, maxBytes: 1024 * 1024, sizeOf: (block, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(block)) @@ -29,7 +33,7 @@ export class AcpBackgroundTaskTimeline { ): ProviderTimelineEvent[] { return updates.map((update) => { const { fallbackLabel, fallbackKind, ...fields } = update - const previous = this.snapshots.get(update.taskId) + const previous = this.running.get(update.taskId) ?? this.settled.get(update.taskId) const block: NativeChatBackgroundTaskBlock = { type: 'background-task', ...previous, @@ -44,7 +48,13 @@ export class AcpBackgroundTaskTimeline { block[key] = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } } - this.snapshots.set(block.taskId, block) + if (isSettledBackgroundTaskState(block.state)) { + this.running.delete(block.taskId) + this.settled.set(block.taskId, block) + } else { + this.settled.delete(block.taskId) + this.running.set(block.taskId, block) + } const turn = block.parentToolUseId ? this.toolTurn(block.parentToolUseId) : undefined return { type: 'item.update', diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index fb39656239ff..1770448230f6 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -88,9 +88,15 @@ export type AcpModelDiscovery = | { kind: 'unavailable'; reason: string } /** What a new chat offers, read from a session opened in the asking chat's folder and then - * deleted. An agent that cannot delete one is not asked, and with no chat folder (a - * prewarm) none is opened, so no conversation or new project is left in its history. */ -export type AcpSessionModelDiscovery = { kind: 'session'; listingNamesConfiguredModel: boolean } + * deleted. An agent that cannot delete one is not asked unless it can keep that session in a + * folder Orca removes, and with no chat folder (a prewarm) none is opened, so no conversation or + * new project is left in its history. */ +export type AcpSessionModelDiscovery = { + kind: 'session' + listingNamesConfiguredModel: boolean + /** Arguments that keep the listing's session in `folder` instead of the agent's own store. */ + sessionFolderArgs?(folder: string): string[] +} /** Whether some release lists in a session in the asking chat's folder, so the listing needs one * and reads that folder's project config. */ @@ -279,10 +285,12 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { // Stable releases from 17.0.5, the release verified to serve `omp acp`. supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), // `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it - // could offer models a chat refuses; its chats' own listings fill the catalog instead. + // could offer models a chat refuses. A session lists exactly what a chat in that folder offers; + // `omp acp` cannot delete one, so it is kept out of OMP's store (17.0.5 and 18.12.2 verified). modelDiscovery: { - kind: 'unavailable', - reason: 'omp has no session-free listing that matches what its chats offer' + kind: 'session', + listingNamesConfiguredModel: false, + sessionFolderArgs: (folder) => ['--session-dir', folder] }, visualsSkill: loadOmpVisualsSkill, compaction: true, diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index d7fab2068db4..073c889cedfd 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { @@ -285,6 +288,39 @@ describe('ACP model catalog probes', () => { expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) }) + // `omp acp` advertises no delete, so its listing session goes to a folder the probe removes. + it('lists OMP from a session kept in a folder it removes, in the chat folder', async () => { + const { agent, deps, launches } = scriptedOpenCode2(false) + const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-omp-listing-')) + try { + const success = await createAcpModelCatalogProbe(acpLaunchSpecFor('omp')!, { + ...deps, + dataDirectory, + resolveCommand: () => '/opt/omp/bin/omp', + probeVersion: async (_launch, supports) => supports('18.12.2') + })( + { variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' }, + { workspacePath: '/work/project' } + ) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'session/new']) + expect(agent.frames[1]!.params).toMatchObject({ cwd: '/work/project' }) + const root = join(dataDirectory, 'acp-listing-sessions', 'omp') + expect(launches).toEqual([ + expect.objectContaining({ + args: ['acp', '--session-dir', expect.stringMatching(/run-/)] + }) + ]) + expect(dirname(launches[0]!.args[2]!)).toBe(root) + expect(await readdir(root)).toEqual([]) + expect(success.models.map((model) => model.id)).toEqual([ + 'opencode/big-pickle', + 'opencode/exo-free' + ]) + } finally { + await rm(dataDirectory, { recursive: true, force: true }) + } + }) + it('opens no session on OpenCode 2.x without a chat folder, as at a prewarm', async () => { const { agent, deps, launches } = scriptedOpenCode2(true) await expect( diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index 39996dbf31e0..e619e1e64af2 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -1,4 +1,5 @@ import { homedir } from 'node:os' +import { join } from 'node:path' import { probeAgentCliVersion } from '../agent-cli-version-probe' import type { AgentModelCatalogProbe, @@ -12,10 +13,12 @@ import { AgentModelCatalogListingStoppedError } from '../native-chat/agent-model import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { createAcpAgentConnection } from './acp-agent-connection' +import { acpRunFolder } from './acp-private-temp' import { acpListingNeedsChatFolder, type AcpLaunchSpec, - type AcpModelDiscovery + type AcpModelDiscovery, + type AcpSessionModelDiscovery } from './acp-launch-specs' import type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' import { @@ -108,11 +111,22 @@ export function createAcpModelCatalogProbe( if (!workspace) { throw folderlessListing(spec) } - models = await runAgentModelCatalogSession( - () => (deps.connectSession ?? createAcpAgentConnection)(launch), - (connection) => readSessionModels(spec, connection, workspace), - run - ) + // Kept out of the agent's own store, and removed once `close` has ended the process. + const folderArgs = listing.sessionFolderArgs + const folder = folderArgs + ? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent)) + : null + try { + const args = + folderArgs && folder ? [...launch.args, ...folderArgs(folder.path)] : launch.args + models = await runAgentModelCatalogSession( + () => (deps.connectSession ?? createAcpAgentConnection)({ ...launch, args }), + (connection) => readSessionModels(spec, listing, connection, workspace), + run + ) + } finally { + await folder?.remove() + } } else { models = await runAgentModelCatalogSession( () => (deps.connect ?? createAcpAgentConnection)(launch), @@ -145,21 +159,26 @@ function listingFor(discovery: AcpModelListing, version: string | null): AcpMode : null } -/** What a new chat in `workspace` offers, read from a session opened only to be deleted. */ +/** What a new chat in `workspace` offers, read from a session opened only to be deleted, or kept + * in a folder the probe removes. */ async function readSessionModels( spec: AcpLaunchSpec, + listing: AcpSessionModelDiscovery, connection: AcpModelCatalogSessionConnection, workspace: string ): Promise { const initialized = await connection.initialize() - if (!initialized.agentCapabilities?.sessionCapabilities?.delete) { + const deletes = Boolean(initialized.agentCapabilities?.sessionCapabilities?.delete) + if (!deletes && !listing.sessionFolderArgs) { throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`) } const started = await connection.start({ cwd: workspace, mcpServers: [] }) // Best effort: a failed delete, or a listing stopped mid-create, leaves one empty conversation. - await connection.deleteSession(started.sessionId).catch((error: unknown) => { - console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error) - }) + if (deletes) { + await connection.deleteSession(started.sessionId).catch((error: unknown) => { + console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error) + }) + } const options = new AcpStructuredOptions(spec.dialect) options.adoptSession(started.response, 'new') return options.read().models diff --git a/src/main/acp/acp-private-temp.ts b/src/main/acp/acp-private-temp.ts index c3db84be721e..c271d09261a7 100644 --- a/src/main/acp/acp-private-temp.ts +++ b/src/main/acp/acp-private-temp.ts @@ -7,23 +7,19 @@ import { join } from 'node:path' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AcpStructuredConnection, ConnectAcpAgent } from './acp-structured-connection' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const wipes = new Map>() -/** `deps.connect`, giving the child a new folder as its temp and removing it once the child exits. */ -export async function acpPrivateTempConnect< - Connection extends Pick = AcpStructuredConnection ->( - deps: Pick & { - connect: ConnectAcpAgent - }, - closedBeforeSpawn: () => void -): Promise> { - const root = join(deps.dataDirectory, 'acp-temp', deps.spec.agent) - // Best effort: Windows can hold a handle past the kill; the next host start clears what stays. +/** A new folder under `root` for one agent process. The host's first use of `root` clears it: + * a crash, or a handle Windows holds past the kill, can leave an earlier run's folder behind. */ +export async function acpRunFolder( + root: string, + logger?: StructuredAgentSessionLogger +): Promise<{ path: string; remove(): Promise }> { const remove = (path: string): Promise => rm(path, { recursive: true, force: true }).catch((error: unknown) => - deps.logger?.warn('ACP agent temp folder not removed', { scope: 'acp-temp', path, error }) + logger?.warn('ACP agent temp folder not removed', { scope: 'acp-temp', path, error }) ) let wipe = wipes.get(root) if (!wipe) { @@ -31,20 +27,35 @@ export async function acpPrivateTempConnect< wipes.set(root, wipe) } await wipe - const folder = await mkdir(root, { recursive: true }) - .then(() => mkdtemp(join(root, 'run-'))) - .catch((error: unknown) => { - throw new AgentSessionPreSpawnError(error) - }) + await mkdir(root, { recursive: true }) + const path = await mkdtemp(join(root, 'run-')) + return { path, remove: () => remove(path) } +} + +/** `deps.connect`, giving the child a new folder as its temp and removing it once the child exits. */ +export async function acpPrivateTempConnect< + Connection extends Pick = AcpStructuredConnection +>( + deps: Pick & { + connect: ConnectAcpAgent + }, + closedBeforeSpawn: () => void +): Promise> { + const folder = await acpRunFolder( + join(deps.dataDirectory, 'acp-temp', deps.spec.agent), + deps.logger + ).catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) return (launch, options) => { try { closedBeforeSpawn() - const env = { ...launch.env, TMPDIR: folder, TEMP: folder, TMP: folder } + const env = { ...launch.env, TMPDIR: folder.path, TEMP: folder.path, TMP: folder.path } const connection = deps.connect({ ...launch, env }, options) - connection.onExit(() => void remove(folder)) + connection.onExit(() => void folder.remove()) return connection } catch (error) { - void remove(folder) + void folder.remove() throw error } } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index cfad5c453105..8c62c52b4822 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -250,13 +250,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte input ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) - backgroundTaskStops: NonNullable = ( - sessionId - ) => acpChildStopCapabilities(this.sessions.get(sessionId)) + backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id)) closeSession = (sessionId: string): Promise => this.close(sessionId) disposeSession = (sessionId: string): Promise => this.close(sessionId) releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.sessions.has(sessionId) || this.starts.failedSessionIds().includes(sessionId) /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) diff --git a/src/main/acp/acp-subagents.ts b/src/main/acp/acp-subagents.ts index a8ce14c9a55b..e4b53b2f4395 100644 --- a/src/main/acp/acp-subagents.ts +++ b/src/main/acp/acp-subagents.ts @@ -6,7 +6,6 @@ // not known here. import { createHash } from 'node:crypto' -import { BoundedMap } from '../../shared/bounded-map' import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, @@ -24,9 +23,6 @@ import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' import type { AcpTimelineEvent } from './acp-timeline-event' -/** Subagents remembered, so a later report about one is still known as a subagent's. */ -const MAX_KNOWN_SUBAGENTS = 2048 - type KnownSubagent = { /** Where its reply row goes: the turn that spawned it. */ placement: ProviderTimelineJoin @@ -54,9 +50,8 @@ export function acpSubagentOutcome( } export class AcpSubagents { - private readonly known = new BoundedMap({ - maxEntries: MAX_KNOWN_SUBAGENTS - }) + /** Every subagent this provider child reported, so a later report is still known as one's. */ + private readonly known = new Map() private readonly pending: AgentChildWorkEvidence[] = [] /** Known children include settled ones, so they cannot become background tasks. */ diff --git a/src/main/acp/acp-timeline-background-tasks.test.ts b/src/main/acp/acp-timeline-background-tasks.test.ts index 798e96cdaeff..27808c487b6d 100644 --- a/src/main/acp/acp-timeline-background-tasks.test.ts +++ b/src/main/acp/acp-timeline-background-tasks.test.ts @@ -141,7 +141,7 @@ describe('Grok background tasks through the shared timeline', () => { } ) - it('keeps the row and placement of a task whose snapshot the bound evicted', async () => { + it('keeps the label and placement of a running task beside 130 other running tasks', async () => { const fixture = await openAcpFixtureRig() await fixture.feed(await runningFrames()) const [before] = await taskRows(fixture) @@ -156,7 +156,11 @@ describe('Grok background tasks through the shared timeline', () => { const original = (await taskRows(fixture)).find((task) => task.block.taskId === taskId) expect(original?.row.itemId).toBe(before?.row.itemId) expect(original?.row.turnScope).toEqual(before?.row.turnScope) - expect(original?.block.state).toBe('done') + expect(original?.block).toMatchObject({ + state: 'done', + label: before?.block.label, + parentToolUseId: 'call-1' + }) }) it('places a first task notice beside its original tool after its turn ended and another began', async () => { diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts index a68a7c48a459..eaac59509bf0 100644 --- a/src/main/agent-cli-version-probe.test.ts +++ b/src/main/agent-cli-version-probe.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { isStableCliVersionFrom, isStableCliVersionOnLine, + observeAgentCliVersion, probeAgentCliVersion } from './agent-cli-version-probe' @@ -59,16 +60,20 @@ describe('probeAgentCliVersion', () => { ['oversized output', { outputTruncated: true }] ])('cannot tell after %s', async (_label, result) => { prints('1.18.31\n', result) + expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) }) it('cannot tell when the output names no version, or the program does not start', async () => { prints('usage: agent [options]\n') - expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') runProcess.mockImplementationOnce(() => { throw new Error('spawn ENOENT') }) - expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') + // Only a release it read and refused is a verdict. + prints('1.18.31\n') + expect(await observeAgentCliVersion(INPUT, () => false)).toBe('unsupported') }) }) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index 61d55ec2106b..6e176fc72a2b 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -12,22 +12,33 @@ function warnRefused(program: string, why: string, output = ''): void { console.warn(`[agent-cli-version] ${program} --version: ${why}${said ? `: ${said}` : ''}`) } -/** Runs ` --version` on this host and asks `supports` about the version it prints. False - * when it cannot tell: a failed spawn, a non-zero exit, a timeout, or output with no version. */ -export async function probeAgentCliVersion( +/** What one `--version` run showed; `inconclusive` is no verdict on the installed release. */ +export type AgentCliVersionObservation = 'supported' | 'unsupported' | 'inconclusive' + +/** Runs ` --version` on this host and asks `supports` about the version it prints. + * Inconclusive when it cannot tell: a failed spawn, a non-zero exit, a timeout, or no version. */ +export async function observeAgentCliVersion( input: Pick, supports: (version: string) => boolean -): Promise { +): Promise { const result = await readAgentCliVersion(input) if (result.version === null) { - return false + return 'inconclusive' } if (!supports(result.version)) { warnRefused(input.program, `${result.version} is not a supported release`) - return false + return 'unsupported' } console.info(`[agent-cli-version] ${input.program} --version: ${result.version} is supported`) - return true + return 'supported' +} + +/** A launch's projection: only an observed supported release runs; an inconclusive run refuses. */ +export async function probeAgentCliVersion( + input: Pick, + supports: (version: string) => boolean +): Promise { + return (await observeAgentCliVersion(input, supports)) === 'supported' } export async function readAgentCliVersion( diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 08f0ba89474e..e4266558c97b 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -38,6 +38,7 @@ export function agentHookRuntimeDeps( readChildWorkRecords: (subject) => server.getStructuredChildWork(subject), readChildWorkHandles: (subject) => server.getStructuredChildWorkHandles(subject) }, + readHookCliWriterObservation: (row) => server.readCliWriterObservation(row), checkHookAgentPresence: (paneKey, exitedSince) => exitedSince === undefined ? server.checkAgentPresence(paneKey) diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 1c674cd21857..0d159fad3a50 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -23,6 +23,10 @@ afterEach(() => { }) class PresenceTestServer extends AgentHookServer { + persistedWriterState(): string { + return this.serializeStatusFile() + } + applyTranscriptUpdate(): void { const row = this.state.lastStatusByPaneKey.get(PANE) if (row) { @@ -78,6 +82,29 @@ function visible(server: AgentHookServer): boolean { } describe('host-owned hook presence', () => { + it('keeps writer observations exact and topology local to the accepted row', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + expect(row).toBeDefined() + if (!row) { + throw new Error('Missing hook row') + } + const observed = server.readCliWriterObservation(row) + probe.mockResolvedValue('exited') + expect(await observed?.presence()).toBe('exited') + expect(observed?.current()).toBe(true) + expect( + server.readCliWriterObservation({ + ...row, + providerSession: { key: 'session_id', id: 'other' } + }) + ).toBeNull() + await hook(server, 'UserPromptSubmit') + expect(observed?.current()).toBe(false) + expect(server.persistedWriterState()).not.toContain('pane-owned') + }) + it('clears the status on SessionEnd while the terminal survives, without a renderer', async () => { const server = await createServer() await hook(server, 'SessionStart') diff --git a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts index e540598f1a1a..18a38aef82e8 100644 --- a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' @@ -210,33 +209,4 @@ describe('Claude child permission lifecycle', () => { server.stop() } }) - - it('clears an omitted teammate permission when a full roster excludes its row', async () => { - const { server, postClaudeHook } = await createServer() - try { - await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' }) - for (let index = 0; index < AGENT_STATUS_MAX_SUBAGENTS; index += 1) { - await postClaudeHook({ hook_event_name: 'SubagentStart', agent_id: `acapped${index}` }) - } - await postClaudeHook({ - hook_event_name: 'PermissionRequest', - agent_id: 'areviewer-6d3cb5b5', - agent_type: 'reviewer', - tool_name: 'Bash', - tool_input: { command: 'false' } - }) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) - - await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) - - const status = server.getStatusSnapshot()[0] - expect(status).toMatchObject({ paneKey: PANE, state: 'working', agentType: 'claude' }) - expect(status?.toolName).toBeUndefined() - expect(status?.toolInput).toBeUndefined() - expect(status?.subagents).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) - } finally { - server.stop() - } - }) }) diff --git a/src/main/agent-hooks/server-nested-agent-roster.test.ts b/src/main/agent-hooks/server-nested-agent-roster.test.ts index 238108f17cd2..c015197b7d65 100644 --- a/src/main/agent-hooks/server-nested-agent-roster.test.ts +++ b/src/main/agent-hooks/server-nested-agent-roster.test.ts @@ -5,10 +5,7 @@ import { join } from 'node:path' import { AgentHookServer } from './server' import { buildBody, PANE, postHookEvent } from './server.test-fixtures' import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server' -import { - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_STATUS_STALE_AFTER_MS -} from '../../shared/agent-status-types' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' import type { AgentHookSource } from '../../shared/agent-hook-relay' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) @@ -16,6 +13,8 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) +const CHILD_COUNT = 32 + afterEach(() => vi.restoreAllMocks()) describe('nested agent hooks preserve the owning pane', () => { @@ -58,7 +57,7 @@ describe('nested agent hooks preserve the owning pane', () => { await post('claude', { hook_event_name: 'Stop', session_id: 'claude-parent', - background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, i) => ({ + background_tasks: Array.from({ length: CHILD_COUNT }, (_, i) => ({ id: `child${i}`, type: 'subagent', status: 'running', @@ -72,7 +71,7 @@ describe('nested agent hooks preserve the owning pane', () => { mainAgent: { state: 'done' }, providerSession: { id: 'claude-parent' } }) - expect(before[0]?.subagents).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(before[0]?.subagents).toHaveLength(CHILD_COUNT) pushed.mockClear() for (const hook_event_name of [ 'SessionStart', @@ -111,9 +110,7 @@ describe('nested agent hooks preserve the owning pane', () => { session_id: 'claude-parent', agent_id: 'child0' }) - expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength( - AGENT_STATUS_MAX_SUBAGENTS - 1 - ) + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(CHILD_COUNT - 1) await post('claude', { hook_event_name: 'Stop', session_id: 'claude-parent', diff --git a/src/main/agent-hooks/server-opencode-writer-observation.test.ts b/src/main/agent-hooks/server-opencode-writer-observation.test.ts new file mode 100644 index 000000000000..c0cdb6a27df0 --- /dev/null +++ b/src/main/agent-hooks/server-opencode-writer-observation.test.ts @@ -0,0 +1,145 @@ +import { + AgentStatusObservedPaneIdentities, + AgentStatusObservedPaneIdentityCapture +} from '../runtime/agent-status-observed-pane-identity' +import { OPEN_CODE_WRITER_TOPOLOGY } from './server/cli-writer-observation' +import { toAgentStatusIpcPayload } from './server/server-status-identity' +import { buildRelayHookEnvelope } from '../../wsl-guest/agent-hook-envelope-build' +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: () => ({}) })) +vi.mock('../../shared/agent-process-presence-probe', () => ({ + probeAgentProcessPresence: async () => 'unverifiable' +})) + +class WriterObservationTestServer extends AgentHookServer { + persistedWriterState(): string { + return this.serializeStatusFile() + } +} + +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } +}) + +async function createServer(): Promise { + const server = new WriterObservationTestServer() + servers.push(server) + await server.start({ env: 'production' }) + return server +} + +describe('OpenCode writer observation', () => { + it('retains OpenCode execution evidence through inference and repaint, never replacement', async () => { + const server = await createServer() + const identities = new AgentStatusObservedPaneIdentities() + const capture = new AgentStatusObservedPaneIdentityCapture(identities) + let incarnation = 'execution-a' + const runtime = { + getAgentStatusTerminalHandleForPaneKey: () => 'terminal-a', + getTerminalProcessIncarnation: () => incarnation, + getAgentStatusOrchestrationContextForPaneKey: () => undefined + } + server.subscribeEnrichedStatus((row) => capture.observe(row)) + const row = () => { + const value = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + if (!value) { + throw new Error('Missing OpenCode row') + } + return value + } + const observation = () => server.readCliWriterObservation(row()) + const post = async (extra: Record = { opencodeMajor: 2 }) => { + expect( + ( + await postHookEvent( + server, + { + ...buildBody({ + hook_event_name: 'SessionBusy', + sessionID: 'open-code-conversation', + prompt: 'task' + }), + ...extra + }, + '/hook/opencode2' + ) + ).status + ).toBe(204) + } + await post() + capture.attach(runtime) + server.setCliWriterExecutionMatcher(identities, runtime) + expect(observation()?.topology).toBe('unverifiable') + await post() + const interrupt = () => { + const value = row() + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: value.receivedAt, + baselineStateStartedAt: value.stateStartedAt, + baselinePrompt: value.prompt, + baselineAgentType: value.agentType, + intent: 'ctrl-c' + }) + ).toBe(true) + } + interrupt() + expect(observation()?.topology).toBe('pane-owned') + const repaint = (origin?: 'process') => + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + terminalHandle: 'terminal-a', + origin, + payload: { state: 'done', prompt: 'changed repaint', agentType: 'opencode2' } + }) + repaint() + expect(observation()?.topology).toBe('pane-owned') + const binding = row().providerSession + expect(binding && OPEN_CODE_WRITER_TOPOLOGY in binding).toBe(true) + const snapshot = row() + const event = { + ...snapshot, + restoredUnconfirmed: snapshot.restoredUnconfirmed ? (true as const) : undefined, + payload: { state: 'done' as const, prompt: '', agentType: 'opencode2' } + } + for (const value of [ + event, + toAgentStatusIpcPayload(event), + buildRelayHookEnvelope(event, 'opencode2') + ]) { + expect(JSON.stringify(value)).not.toContain('pane-owned') + expect(Object.getOwnPropertySymbols(structuredClone(value).providerSession ?? {})).toEqual([]) + } + expect(server.persistedWriterState()).not.toContain('pane-owned') + for (const extra of [{}, { opencodeSharedServer: 1 }]) { + await post(extra) + expect(observation()?.topology).not.toBe('pane-owned') + } + await post() + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + origin: 'process', + payload: { state: 'working', prompt: 'task', agentType: 'opencode2' } + }) + expect(observation()?.topology).toBe('unverifiable') + await post() + incarnation = 'execution-b' + interrupt() + expect(identities.read(PANE)).toMatchObject({ processIncarnation: 'execution-b' }) + expect(observation()?.topology).toBe('unverifiable') + repaint() + expect(observation()?.topology).toBe('unverifiable') + }) +}) diff --git a/src/main/agent-hooks/server/cli-writer-observation.ts b/src/main/agent-hooks/server/cli-writer-observation.ts new file mode 100644 index 000000000000..2e42de0510db --- /dev/null +++ b/src/main/agent-hooks/server/cli-writer-observation.ts @@ -0,0 +1,133 @@ +import { + resolveAgentStatusBinding, + type AgentStatusRuntimeEnrichment +} from '../../ipc/agent-status-ipc-boundary' +import type { AgentStatusObservedPaneIdentities } from '../../runtime/agent-status-observed-pane-identity' +import { + isSameAgentProcess, + type AgentProcessIdentity, + type AgentProcessVerdict +} from '../../../shared/agent-process-presence' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' + +// Execution metadata follows the conversation binding and disappears at serialization. +export const OPEN_CODE_WRITER_TOPOLOGY = Symbol('openCodeWriterTopology') + +export type CliWriterObservation = { + current: () => boolean + presence: () => Promise + topology: 'pane-owned' | 'shared' | 'unverifiable' +} + +export function retainOpenCodeWriterTopology( + event: AgentHookEventPayload | null, + source: AgentHookSource, + body: unknown +): void { + if ( + !event?.providerSession || + (source !== 'opencode' && source !== 'opencode2' && source !== 'mimo-code') || + !body || + typeof body !== 'object' + ) { + return + } + const major = 'opencodeMajor' in body ? body.opencodeMajor : undefined + const topology = + typeof major === 'number' && major >= 2 + ? 'pane-owned' + : ('opencodeSharedServer' in body && body.opencodeSharedServer === 1) || + ('opencodeTui' in body && body.opencodeTui === 1) + ? 'shared' + : 'unverifiable' + Object.defineProperty(event.providerSession, OPEN_CODE_WRITER_TOPOLOGY, { value: topology }) +} + +/** OpenCode-family servers can outlive their pane; only the binding they posted can prove otherwise. */ +export function cliWriterTopology(row: AgentHookEventPayload): CliWriterObservation['topology'] { + const binding = row.providerSession + const topology = + binding && OPEN_CODE_WRITER_TOPOLOGY in binding ? binding[OPEN_CODE_WRITER_TOPOLOGY] : undefined + return topology === 'shared' + ? 'shared' + : row.source !== 'opencode' && + row.source !== 'opencode2' && + row.source !== 'mimo-code' && + row.payload.agentType !== 'opencode' && + row.payload.agentType !== 'opencode2' + ? 'pane-owned' + : topology === 'pane-owned' + ? topology + : 'unverifiable' +} + +/** A tmux server outlives the pane it was started from, so nothing it publishes is pane-owned. */ +export function tmuxCliWriterEvent(row: T): T { + if (!row.providerSession) { + return row + } + const providerSession = { ...row.providerSession } + Object.defineProperty(providerSession, OPEN_CODE_WRITER_TOPOLOGY, { value: 'shared' }) + return { ...row, providerSession } +} + +export type CliWriterExecutionMatches = ( + row: AgentHookEventPayload, + baseline?: AgentHookEventPayload +) => boolean + +export function cliWriterExecutionMatcher( + identities: Pick, + runtime: AgentStatusRuntimeEnrichment +): CliWriterExecutionMatches { + return (row, baseline) => { + const current = resolveAgentStatusBinding(row.paneKey, runtime) + if ( + current.kind === 'unresolved' || + (row.terminalHandle && row.terminalHandle !== current.terminalHandle) + ) { + return false + } + if (!baseline) { + return true + } + const observed = identities.read(baseline.paneKey) + return ( + observed.kind === 'observed' && + observed.terminalHandle === current.terminalHandle && + observed.processIncarnation === current.processIncarnation + ) + } +} + +/** Validate before enriched listeners can recapture a replacement execution as this row's owner. */ +export function deriveCliWriterTopology( + row: T, + baseline: AgentHookEventPayload | undefined, + matches: CliWriterExecutionMatches | null, + newRun = false, + sender: AgentProcessIdentity | undefined = row.agentPresence?.process +): T { + const binding = row.providerSession + if (!binding || !(OPEN_CODE_WRITER_TOPOLOGY in binding)) { + return row + } + const inherited = baseline?.providerSession === binding + const owner = baseline?.agentPresence?.process + if ( + !newRun && + row.connectionId === null && + (!sender || !owner || isSameAgentProcess(sender, owner)) && + (!inherited || + (baseline.connectionId === null && + baseline.worktreeId === row.worktreeId && + baseline.payload.agentType === row.payload.agentType && + baseline.providerSession?.key === binding.key && + baseline.providerSession.id === binding.id)) && + matches?.(row, inherited ? baseline : undefined) + ) { + return row + } + return { ...row, providerSession: { ...binding } } +} diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 327641381970..175773066d6b 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,3 +1,5 @@ +import { cliWriterTopology, type CliWriterObservation } from './cli-writer-observation' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import { isSameAgentProcess, @@ -46,6 +48,37 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy return presence?.process !== undefined && !presence.ended } + /** A local inspection keeps the exact hook row, rather than probing the pane's next owner. */ + readCliWriterObservation(expected: AgentStatusIpcPayload): CliWriterObservation | null { + const resolved = this.resolvePaneKeyAlias(expected.paneKey) + const row = this.state.lastStatusByPaneKey.get(resolved) + if ( + !row || + row.connectionId !== null || + row.worktreeId !== expected.worktreeId || + row.providerSession?.id !== expected.providerSession?.id || + row.payload.agentType !== expected.agentType || + !('receivedAt' in row) || + row.receivedAt !== expected.receivedAt + ) { + return null + } + const token = this.token + const generation = this.statusHookGeneration + const presence = row.agentPresence + return { + current: () => + this.statusHooksEnabled && + this.token === token && + this.statusHookGeneration === generation && + this.state.lastStatusByPaneKey.get(resolved) === row && + row.agentPresence === presence, + presence: () => + presence?.process ? probeAgentProcessPresence(presence.process) : Promise.resolve(null), + topology: cliWriterTopology(row) + } + } + /** As checkAgentPresence, but keeps an exit this host proved at or after `exitedSince`. */ async checkAgentPresenceSince( paneKey: string, diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 924a0bde76e7..aea81cad7fab 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -1,3 +1,4 @@ +import { deriveCliWriterTopology } from './cli-writer-observation' import { admitLegacyAgentStatus, movePaneCacheState @@ -175,11 +176,15 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut admitLegacyAgentStatus( this.state, 'main-pane-alias-transfer', - { - ...movedStatus, - paneKey: toPaneKey, - tabId: owner?.tabId - }, + deriveCliWriterTopology( + { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }, + previousStatus, + this.cliWriterExecutionMatches + ), AGENT_STATUS_2A_CURRENT_PRODUCER_MODE ) } diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index d18238aa28f6..7d998e85c22a 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -1,3 +1,4 @@ +import { retainOpenCodeWriterTopology } from './cli-writer-observation' import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' import { checkHookOwnerPresence } from '../../../shared/agent-hook-owner-presence' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' @@ -46,6 +47,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer return disposition !== 'suppress' } }) + retainOpenCodeWriterTopology(event, source, body) if ( event && event.hookEventName === 'SessionStart' && diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index a3ecc368c344..ab621097860b 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -148,10 +148,11 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges worktreeId, connectionId }) + const newRun = event.origin === 'process' && event.payload.state === 'working' // 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' + newRun ? { processNewTurn: true } : this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey)?.allowRetainedOwner ? { retainedLaunchTokenHash } @@ -276,7 +277,8 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges this.refreshTerminalStatusEvidence( previous, mutationBefore, - mutationBefore !== undefined || event.renewsProgramStatusLease === true + mutationBefore !== undefined || event.renewsProgramStatusLease === true, + newRun ) return } @@ -326,7 +328,8 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges undefined, event.origin ?? 'osc', undefined, - mutationBefore + mutationBefore, + newRun ) } } diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 49ca647a9326..d9ec13aac6cc 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -1,3 +1,4 @@ +import { cliWriterExecutionMatcher, type CliWriterExecutionMatches } from './cli-writer-observation' import { isTmuxInnerSubject } from '../../../shared/tmux-agent-hook-owner' import type { AgentStatusClearIpcPayload, @@ -28,6 +29,11 @@ import { ProgramStatusFacetStore } from './program-status/facet-store' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + protected cliWriterExecutionMatches: CliWriterExecutionMatches | null = null + + setCliWriterExecutionMatcher(...args: Parameters): void { + this.cliWriterExecutionMatches = cliWriterExecutionMatcher(...args) + } // Why here: every publish below stitches from it, and all later layers manage its lifecycle. protected readonly programStatusFacets = new ProgramStatusFacetStore() /** Yielded OSC 7501 reports, re-offered when hook precedence lapses: programs never resend a state. */ diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 674fa8a0760a..d2bb321ab4c1 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -1,3 +1,4 @@ +import { deriveCliWriterTopology } from './cli-writer-observation' import { createHash } from 'node:crypto' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' @@ -58,7 +59,8 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt protected refreshTerminalStatusEvidence( previous: EnrichedAgentHookEventPayload, mutationBefore?: EnrichedAgentHookEventPayload, - emitEnrichedStatus = false + emitEnrichedStatus = false, + newRun = false ): void { if (!this.canWriteLegacyStatusRow(previous)) { return @@ -79,13 +81,13 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt isReplay: _isReplay, ...payload } = previous - const refreshed: EnrichedAgentHookEventPayload = { + const refreshed = deriveCliWriterTopology({ ...payload, receivedAt: now, evidenceObservedAt: now, stateStartedAt, observation: this.stampObservation(payload, 'osc', now) - } + }, mutationBefore ?? previous, this.cliWriterExecutionMatches, newRun) const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) if (!this.writeLegacyStatusRow(refreshed)) { diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index c80a70dfe1a6..3b92363db502 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 { deriveCliWriterTopology } from './cli-writer-observation' import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' import { reconcileRemoteCodexState, @@ -49,7 +50,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, - mutationBefore?: EnrichedAgentHookEventPayload + mutationBefore?: EnrichedAgentHookEventPayload, + newRun = false ): EnrichedAgentHookEventPayload | undefined { const transitioned = transitionHookPresence( incoming, @@ -139,10 +141,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (terminalOwnedPayload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() - const enriched = { + const enriched = deriveCliWriterTopology({ ...this.attachStatusTiming(terminalOwnedPayload, now), observation: this.stampObservation(terminalOwnedPayload, origin, now) - } + }, rowBefore, this.cliWriterExecutionMatches, newRun, incoming.agentPresence?.process) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) if (!this.writeLegacyStatusRow(enriched)) { @@ -268,10 +270,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA runningNonAgentTask === undefined ? unpairedPayload : { ...unpairedPayload, claudeRunningNonAgentTask: runningNonAgentTask } - const enriched = { + const enriched = deriveCliWriterTopology({ ...this.attachStatusTiming(pairedPayload, now, observedAt), observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) - } + }, rowBefore, this.cliWriterExecutionMatches, newRun, incoming.agentPresence?.process) if ( typeof enriched.payload.turnCompletedAt === 'number' && Number.isFinite(enriched.payload.turnCompletedAt) diff --git a/src/main/agent-hooks/server/server-tmux-status.ts b/src/main/agent-hooks/server/server-tmux-status.ts index 59df76c7ed64..8d93105d231d 100644 --- a/src/main/agent-hooks/server/server-tmux-status.ts +++ b/src/main/agent-hooks/server/server-tmux-status.ts @@ -7,6 +7,7 @@ import { import { structuredStatusLegacyEvent } from './server-structured-status-row' import type { AgentHookSource } from '../../../shared/agent-hook-relay' import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' +import { tmuxCliWriterEvent } from './cli-writer-observation' export abstract class AgentHookServerTmuxStatus extends AgentHookServerOpenCodeBinder { private tmuxOwner: TmuxAgentHookOwner | undefined @@ -56,7 +57,7 @@ export abstract class AgentHookServerTmuxStatus extends AgentHookServerOpenCodeB this.commitStatusRowMutation(previous && structuredStatusLegacyEvent(previous), after) this.recordCurrentAuthorityObservation(after) this.notifyStatusChangeListeners() - this.emitEnrichedStatus(after) + this.emitEnrichedStatus(tmuxCliWriterEvent(after)) }, unavailable: (paneKey, subject) => { if (this.getAgentStatusDisposition(paneKey) === 'suppress') { diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts index 18c8525a886a..b2a738395030 100644 --- a/src/main/agent-launch/agent-launch-pane-attachment.ts +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -136,6 +136,11 @@ export function forgetFailedAgentLaunches( } } +/** Workspaces still holding a failed launch, for a project removal that has no other list. */ +export function listFailedAgentLaunchWorktreeIds(): string[] { + return [...failedLaunchesByPane.values()].map((failed) => failed.pane.worktreeId) +} + /** A launch still owns this pane: only its own spawn may put a process there. */ export function isAgentLaunchRunningIn( worktreeId: string, diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index 9fcdeb7dadf2..fda6a19143f6 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -93,8 +93,8 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { const request = { limit: 1000, scopePaths: ['/project'] } const first = await listAiVaultSessions(request) - // A narrower request reuses the same scan entry and its scan ID. - const cached = await listAiVaultSessions({ ...request, limit: 100 }) + // A repeat request reuses the same scan entry and its scan ID. + const cached = await listAiVaultSessions(request) expect(cached.scannedAt).toBe('scan-A') expect(cached.scanId).toBe(first.scanId) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 99a3d7bdc7d1..7b7db0131f2f 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -22,7 +22,7 @@ import { type LocalAiVaultScanProgressHandle } from './ai-vault-local-scan-progress' import { - aiVaultSessionDepthCovers, + aiVaultCachedDepthServes, requestedAiVaultSessionDepth, truncateAiVaultListResult, type AiVaultSessionDepth @@ -119,7 +119,7 @@ export async function listAiVaultSessions( args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now && - aiVaultSessionDepthCovers(cachedList.depth, depth) + aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths) ) { return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } @@ -164,7 +164,7 @@ export async function listAiVaultSessions( args?.force === true || current?.key !== key || current.expiresAt <= Date.now() || - !aiVaultSessionDepthCovers(current.depth, depth) + !aiVaultCachedDepthServes(current.depth, depth, args?.scopePaths) ) { cachedList = { key, diff --git a/src/main/ai-vault/session-parse-cache-database.ts b/src/main/ai-vault/session-parse-cache-database.ts index 3848ff31d396..e5e96ea496fd 100644 --- a/src/main/ai-vault/session-parse-cache-database.ts +++ b/src/main/ai-vault/session-parse-cache-database.ts @@ -9,8 +9,17 @@ import { removeVersionedCacheDatabase } from '../sqlite/versioned-cache-database' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store' -import type { SessionSidecarObservation } from './session-sidecar-stat' +import type { + PersistedSessionParseCacheEntry, + SessionScopeCwdLookup, + SessionScopeCwdStamp +} from './session-parse-cache-store' +import { + ensureScopeCwdsTable, + readScopeCwdRows, + writeScopeCwdRows +} from './session-scope-cwds-table' +import { parsePersistedSidecar } from './session-sidecar-stat' // Bump when the row layout or cached session semantics change; a mismatched // database is dropped whole. @@ -68,6 +77,21 @@ export class SessionParseCacheDatabase { }, undefined) } + /** Each path's parse-row cwd and stored scope cwd, without decoding whole sessions. */ + readScopeCwds(paths: readonly string[]) { + const parsed = this.run((db) => readScopeCwdRows(db, paths, 'parsed'), undefined) + const read = this.run((db) => readScopeCwdRows(db, paths, 'read'), undefined) + const found = parsed ?? new Map() + for (const [path, row] of read ?? []) { + found.set(path, { ...found.get(path), ...row }) + } + return { found, parsed: parsed !== undefined, read: read !== undefined } + } + + writeScopeCwds(rows: Iterable<[string, SessionScopeCwdStamp]>, seenAt: number): void { + this.run((db) => writeScopeCwdRows(db, rows, seenAt), undefined) + } + /** * One transaction; a caller chunks large writes so the event loop gets turns * between them. `keepExisting` leaves rows already present untouched. @@ -105,7 +129,10 @@ export class SessionParseCacheDatabase { } delete(path: string): void { - this.run((db) => db.prepare('DELETE FROM rows WHERE path = ?').run(path), undefined) + this.run((db) => { + db.prepare('DELETE FROM rows WHERE path = ?').run(path) + db.prepare('DELETE FROM scope_cwds WHERE path = ?').run(path) + }, undefined) } /** Oldest first, so repeated calls work through every stale row outside `excludeGlob`. */ @@ -114,7 +141,8 @@ export class SessionParseCacheDatabase { (db) => db .prepare( - 'SELECT path FROM rows WHERE seen_at < ? AND path NOT GLOB ? ORDER BY seen_at LIMIT ?' + `SELECT path FROM (SELECT path, seen_at FROM rows UNION ALL SELECT path, seen_at FROM scope_cwds) + WHERE seen_at < ? AND path NOT GLOB ? GROUP BY path ORDER BY MIN(seen_at) LIMIT ?` ) .all(cutoff, excludeGlob, limit) .flatMap((row) => (typeof row.path === 'string' ? [row.path] : [])), @@ -126,18 +154,23 @@ export class SessionParseCacheDatabase { deleteSeenBefore(cutoff: number, glob: string): boolean { return this.run( (db) => - Number( - db.prepare('DELETE FROM rows WHERE seen_at < ? AND path GLOB ?').run(cutoff, glob).changes - ) > 0, + ['rows', 'scope_cwds'] + .map((table) => + Number( + db.prepare(`DELETE FROM ${table} WHERE seen_at < ? AND path GLOB ?`).run(cutoff, glob) + .changes + ) + ) + .some((changes) => changes > 0), false ) } markSeen(path: string, seenAt: number): void { - this.run( - (db) => db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path), - undefined - ) + this.run((db) => { + db.prepare('UPDATE rows SET seen_at = ? WHERE path = ?').run(seenAt, path) + db.prepare('UPDATE scope_cwds SET seen_at = ? WHERE path = ?').run(seenAt, path) + }, undefined) } /** Hands freed pages back to the file system after deletes. */ @@ -189,7 +222,8 @@ function openDatabase(path: string): SyncDatabase { version: SESSION_PARSE_CACHE_SCHEMA_VERSION, sql: SCHEMA_SQL, directoryMode: PRIVATE_DIRECTORY_MODE, - pageSize: PAGE_SIZE + pageSize: PAGE_SIZE, + afterSchema: ensureScopeCwdsTable }) hardenSqliteDatabaseFiles(path) return db @@ -233,19 +267,3 @@ export function parsePersistedSessionParseCacheRow( function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } - -// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has -// no such field. Absent (or unreadable) means unknown, which costs one re-parse -// of the rows that have a sibling and nothing at all for the rest. -function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined { - if (value === 'none' || value === 'unknown') { - return value - } - if (!isRecord(value)) { - return undefined - } - const { path, mtimeMs, sizeBytes } = value - return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number' - ? { path, mtimeMs, sizeBytes } - : undefined -} diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 4d89875de8b0..65524d580d28 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -11,8 +11,11 @@ import { splitOpenCodeSqliteCandidate } from '../../shared/opencode-sqlite-sessi import { SessionParseCacheDatabase } from './session-parse-cache-database' import { importLegacySessionParseCacheJson } from './session-parse-cache-legacy-json' import { + scopeCwdStampOf, setSessionParseCacheColdTier, - type PersistedSessionParseCacheEntry + type PersistedSessionParseCacheEntry, + type SessionScopeCwdRead, + type SessionScopeCwdStamp } from './session-parse-cache-store' const WRITE_DEBOUNCE_MS = 1_500 @@ -41,6 +44,7 @@ let owner = false // undefined: not opened yet; null: unavailable for this process's life. let database: SessionParseCacheDatabase | null | undefined const pendingWrites = new Map() +const pendingScopeCwds = new Map() let writeTimer: NodeJS.Timeout | null = null let pendingSince: number | null = null @@ -55,7 +59,13 @@ export function configureSessionParseCachePersistence( export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void { configureSessionParseCachePersistence(next) owner = true - setSessionParseCacheColdTier({ read: readRow, stored: queueWrite, deleted: deleteRow }) + setSessionParseCacheColdTier({ + read: readRow, + stored: queueWrite, + deleted: deleteRow, + readScopeCwds, + storedScopeCwd: queueScopeCwdWrite + }) } export function getSessionParseCachePersistenceOptions(): SessionParseCachePersistenceOptions | null { @@ -65,6 +75,7 @@ export function getSessionParseCachePersistenceOptions(): SessionParseCachePersi export function resetSessionParseCachePersistenceForTests(): void { clearWriteTimer() pendingWrites.clear() + pendingScopeCwds.clear() database?.close() database = undefined options = null @@ -89,6 +100,16 @@ export async function flushSessionParseCachePersist(): Promise { await yieldToEventLoop() } } + while (pendingScopeCwds.size > 0) { + const chunk = [...pendingScopeCwds].slice(0, WRITE_CHUNK_ROWS) + for (const [path] of chunk) { + pendingScopeCwds.delete(path) + } + openedDatabase()?.writeScopeCwds(chunk, Date.now()) + if (pendingScopeCwds.size > 0) { + await yieldToEventLoop() + } + } } // Opened on first need, so Electron main (which never looks rows up) never opens it. @@ -151,9 +172,40 @@ function readRow(path: string): PersistedSessionParseCacheEntry | undefined { return pendingWrites.get(path) ?? openedDatabase()?.read(path) } +function readScopeCwds(paths: readonly string[]): SessionScopeCwdRead { + const result: SessionScopeCwdRead = openedDatabase()?.readScopeCwds(paths) ?? { + found: new Map(), + parsed: false, + read: false + } + const found = result.found + for (const path of paths) { + const row = pendingWrites.get(path) + const read = pendingScopeCwds.get(path) + if (row !== undefined || read !== undefined) { + found.set(path, { + ...found.get(path), + ...(row === undefined ? {} : { parsed: scopeCwdStampOf(row) }), + ...(read === undefined ? {} : { read }) + }) + } + } + return result +} + +function queueScopeCwdWrite(path: string, stamp: SessionScopeCwdStamp): void { + pendingScopeCwds.delete(path) + pendingScopeCwds.set(path, stamp) + scheduleFlush() +} + function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void { pendingWrites.delete(path) pendingWrites.set(path, entry) + scheduleFlush() +} + +function scheduleFlush(): void { const now = Date.now() pendingSince ??= now if (writeTimer) { @@ -169,6 +221,7 @@ function queueWrite(path: string, entry: PersistedSessionParseCacheEntry): void // Immediate, so a queued write or a later lookup can't bring the row back. function deleteRow(path: string): void { pendingWrites.delete(path) + pendingScopeCwds.delete(path) openedDatabase()?.delete(path) } diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts index dc6f985511fd..43cd2871f41a 100644 --- a/src/main/ai-vault/session-parse-cache-store.ts +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -54,12 +54,29 @@ export type PersistedSessionParseCacheEntry = Omit< 'resume' | 'foldSession' | 'skippedRecords' > +// A transcript version's cwd for scope membership: null means the version is +// known to have none, so no scope holds it. +export type SessionScopeCwdStamp = Pick< + PersistedSessionParseCacheEntry, + 'mtimeMs' | 'sizeBytes' | 'platform' | 'sidecar' +> & { cwd: string | null } + +// `parsed` (from a parse row) outranks `read` (learned without a parse). +export type SessionScopeCwdLookup = { parsed?: SessionScopeCwdStamp; read?: SessionScopeCwdStamp } +export type SessionScopeCwdRead = { + found: Map + parsed: boolean + read: boolean +} + // The database behind this map (session-parse-cache-persistence.ts). A row the // map's bounds drop is read back from it rather than re-parsed. export type SessionParseCacheColdTier = { read(path: string): PersistedSessionParseCacheEntry | undefined stored(path: string, entry: PersistedSessionParseCacheEntry): void deleted(path: string): void + readScopeCwds?(paths: readonly string[]): SessionScopeCwdRead + storedScopeCwd?(path: string, stamp: SessionScopeCwdStamp): void } const cache = new Map() @@ -70,10 +87,19 @@ const listedPaths = new Set() // count-bounded sets. const unlistedPaths = new Set() const resumePaths = new Set() +// Scope cwds learned without a parse, for the paths the latest scope walk found. +const readScopeCwds = new Map() +const scopeKnowledge = new Map() let coldTier: SessionParseCacheColdTier | null = null export function setSessionParseCacheColdTier(next: SessionParseCacheColdTier | null): void { coldTier = next + clearSessionScopeCwdKnowledge() + readScopeCwds.clear() +} + +export function clearSessionScopeCwdKnowledge(): void { + scopeKnowledge.clear() } export function resetSessionParseCacheForTests(): void { @@ -81,6 +107,8 @@ export function resetSessionParseCacheForTests(): void { listedPaths.clear() unlistedPaths.clear() resumePaths.clear() + readScopeCwds.clear() + clearSessionScopeCwdKnowledge() coldTier = null } @@ -90,6 +118,8 @@ export function invalidateSessionParseCacheEntry(path: string): void { unlistedPaths.delete(path) resumePaths.delete(path) cache.delete(path) + readScopeCwds.delete(path) + scopeKnowledge.delete(path) coldTier?.deleted(path) } @@ -100,7 +130,7 @@ export function noteSessionParseCacheListed(path: string): void { export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined { const entry = cache.get(path) - if (entry !== undefined || coldTier === null) { + if (entry !== undefined || coldTier === null || scopeKnowledge.get(path) === 'unresolved') { return entry } const persisted = coldTier.read(path) @@ -117,10 +147,86 @@ export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry export function peekSessionParseCacheEntry( path: string ): PersistedSessionParseCacheEntry | undefined { - return cache.get(path) ?? coldTier?.read(path) + return ( + cache.get(path) ?? + (scopeKnowledge.get(path) === 'unresolved' ? undefined : coldTier?.read(path)) + ) +} + +/** Scope cwds for many paths at once: memory first, then one batched database read. */ +export function lookupSessionScopeCwds(paths: readonly string[]): void { + const cold = paths.filter((path) => !cache.has(path)) + const stored = + coldTier === null || cold.length === 0 + ? { found: new Map(), parsed: true, read: true } + : coldTier.readScopeCwds?.(cold) + for (const path of paths) { + const row = stored?.found.get(path) + const parsed = row?.parsed ?? (stored?.parsed ? 'absent' : 'unresolved') + scopeKnowledge.set(path, cache.has(path) ? 'changed' : parsed) + const read = row?.read + if (read && !readScopeCwds.has(path)) { + readScopeCwds.set(path, read) + } + } +} + +export function resolveSessionScopeCwd( + path: string, + matches: (stamp: SessionScopeCwdStamp) => boolean +): { cwd: string | null } | 'read' | 'parse' { + const hot = cache.get(path) + let parsed = hot ? scopeCwdStampOf(hot) : scopeKnowledge.get(path) + if (parsed === 'changed') { + const stored = coldTier?.readScopeCwds?.([path]).found.get(path) + parsed = stored?.parsed ?? 'unresolved' + scopeKnowledge.set(path, parsed) + if (stored?.read && !readScopeCwds.has(path)) { + readScopeCwds.set(path, stored.read) + } + } + if (parsed === undefined || parsed === 'unresolved') { + scopeKnowledge.set(path, 'unresolved') + return 'parse' + } + for (const stamp of [typeof parsed === 'object' ? parsed : undefined, readScopeCwds.get(path)]) { + if (stamp && matches(stamp)) { + return { cwd: stamp.cwd } + } + } + return 'read' +} + +export function storeSessionScopeCwd(path: string, stamp: SessionScopeCwdStamp): void { + readScopeCwds.set(path, stamp) + coldTier?.storedScopeCwd?.(path, stamp) +} + +/** Keeps in memory only the read cwds of transcripts the latest scope walk found. */ +export function pruneSessionScopeCwds(discoveredPaths: ReadonlySet): void { + for (const map of [readScopeCwds, scopeKnowledge]) { + for (const path of map.keys()) { + if (!discoveredPaths.has(path)) { + map.delete(path) + } + } + } +} + +export function scopeCwdStampOf(entry: PersistedSessionParseCacheEntry): SessionScopeCwdStamp { + return { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + cwd: entry.session?.cwd ?? null, + ...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar }) + } } export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void { + if (scopeKnowledge.has(path)) { + scopeKnowledge.set(path, 'changed') + } const previous = cache.get(path) const changed = previous === undefined || diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts index 5ef97f09cb04..b36c2be5dcbd 100644 --- a/src/main/ai-vault/session-scanner-cline-parser.ts +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -68,6 +68,16 @@ function isMissingSessionPathError(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR' } +/** The session's cwd from its manifest alone; null when the manifest lists no session. */ +export function clineManifestCwd(metadataContent: string): string | null { + const metadata = parseJsonRecord(metadataContent) + return metadata ? clineMetadataCwd(metadata) : null +} + +function clineMetadataCwd(metadata: Record): string | null { + return extractString(metadata.cwd) ?? extractString(metadata.workspace_root) +} + export function parseClineSessionContent( file: FileWithMtime, metadataContent: string, @@ -88,7 +98,7 @@ export function parseClineSessionContent( sessionId, messages: messageSink }) - accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.cwd = clineMetadataCwd(metadata) accumulator.model = extractString(metadata.model) updateTimeline(accumulator, metadata.started_at) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts index de20f1eebc01..4d1d2503dc47 100644 --- a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts @@ -82,6 +82,9 @@ import { } from './session-scanner-parse-cache' import { getSessionParseCacheEntry, + lookupSessionScopeCwds, + resolveSessionScopeCwd, + scopeCwdStampOf, peekSessionParseCacheEntry, type PersistedSessionParseCacheEntry, setSessionParseCacheColdTier @@ -615,6 +618,16 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript const read = vi.fn((key: string) => rows.get(key)) const tier = { read, + readScopeCwds: (paths: readonly string[]) => ({ + found: new Map( + paths.flatMap((key) => { + const row = rows.get(key) + return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const] + }) + ), + parsed: true, + read: true + }), stored: (key: string, row: PersistedSessionParseCacheEntry) => rows.set(key, row), deleted: (key: string) => rows.delete(key) } @@ -645,3 +658,30 @@ it('recovers a cold Cursor session when its sidecar appears without a transcript expect(found.map((row) => row.cwd)).toEqual(['/repo']) expect(await stat(path)).toMatchObject({ mtimeMs: file.mtimeMs, size: file.sizeBytes }) }) + +it('lists a Cursor session once meta.json names the scope after a scan stored it as cwd-less', async () => { + const cursorHome = await createCursorHome() + const path = await writeTranscript(cursorHome, 'slug', 'chat-1', [ + JSON.stringify({ role: 'user', message: { content: 'Find the bug' } }) + ]) + await utimes(path, 1, 1) + for (const chatId of ['busy-1', 'busy-2']) { + await writeTranscript(cursorHome, 'slug', chatId, [ + JSON.stringify({ role: 'user', message: { content: chatId } }) + ]) + } + const scan = () => + scanAiVaultSessions({ + ...isolatedScanRoots(join(cursorHome, '..')), + cursorProjectsDir: join(cursorHome, 'projects'), + platform: 'darwin', + limit: 1, + scopePaths: ['/repo'] + }) + expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false) + lookupSessionScopeCwds([path]) + expect(resolveSessionScopeCwd(path, () => true)).toEqual({ cwd: null }) + await writeChatMeta(cursorHome, 'workspace-hash', 'chat-1', { cwd: '/repo' }) + const found = (await scan()).sessions.filter((row) => row.filePath === path) + expect(found.map((row) => row.cwd)).toEqual(['/repo']) +}) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts index e1c9b4afdfa2..f010e6cafda5 100644 --- a/src/main/ai-vault/session-scanner-cursor-chat-meta.ts +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.ts @@ -68,6 +68,15 @@ export function wasCursorChatMetaRefused(transcriptPath: string): boolean { return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false } +/** Whether this scan could not look for the transcript's meta.json, so its absence proves nothing. */ +export function cursorChatMetaUnavailable(transcriptPath: string): boolean { + const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath) + return ( + wasCursorChatMetaRefused(transcriptPath) || + (chatsRoot !== null && scanScopedIndex.getStore()?.refusals.has(chatsRoot) === true) + ) +} + /** Chats roots the current scan was refused, for the caller to report as scan issues. */ export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] { const scan = scanScopedIndex.getStore() diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 353decfcd46e..39cc6c533731 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -42,6 +42,20 @@ type ParserSessionOptions = { executionHostPlatform?: NodeJS.Platform | null } +/** The session's cwd; a scope walk reads it from metadata.json without the context file. */ +export function rovoSessionCwd(metadata: Record): string | null { + return firstString(metadata, [ + 'workspace_path', + 'workspacePath', + 'workspace', + 'cwd', + 'working_directory', + 'workingDirectory', + 'project_path', + 'projectPath' + ]) +} + export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, @@ -60,16 +74,7 @@ export async function parseRovoSessionFile( messages }) accumulator.title = firstString(metadata, ['title', 'name', 'summary']) - accumulator.cwd = firstString(metadata, [ - 'workspace_path', - 'workspacePath', - 'workspace', - 'cwd', - 'working_directory', - 'workingDirectory', - 'project_path', - 'projectPath' - ]) + accumulator.cwd = rovoSessionCwd(metadata) updateTimeline( accumulator, extractString(metadata.created_at) ?? extractString(metadata.createdAt) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index d9e88077c39f..4c931b8b4c72 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -45,7 +45,7 @@ export async function parseGrokSessionFile( const info = asRecord(record.info) const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages }) - accumulator.cwd = extractString(info?.cwd) + accumulator.cwd = grokSessionCwd(record) accumulator.title = normalizeTitleText(extractString(record.generated_title) ?? '') ?? normalizeTitleText(extractString(record.session_summary) ?? '') @@ -60,6 +60,11 @@ export async function parseGrokSessionFile( return finalizeSession(accumulator, platform) } +/** The session's cwd; a scope walk reads it from summary.json without the history. */ +export function grokSessionCwd(summary: Record): string | null { + return extractString(asRecord(summary.info)?.cwd) +} + async function consumeGrokChatHistory( accumulator: SessionAccumulator, sessionDir: string diff --git a/src/main/ai-vault/session-scanner-kiro-parser.ts b/src/main/ai-vault/session-scanner-kiro-parser.ts index 8eef66422ea5..56384965b49f 100644 --- a/src/main/ai-vault/session-scanner-kiro-parser.ts +++ b/src/main/ai-vault/session-scanner-kiro-parser.ts @@ -99,13 +99,18 @@ function consumeKiroTranscriptLine(accumulator: SessionAccumulator, line: string } /** null for a subagent's child session: it is reached from its parent, not listed. */ +export function listedKiroMetadata(metadataContent: string): Record | null { + const metadata = parseJsonObject(metadataContent) + return metadata && !extractString(metadata.parent_session_id) ? metadata : null +} + function createKiroAccumulator( file: FileWithMtime, metadataContent: string, messages?: TranscriptMessageSink ): SessionAccumulator | null { - const metadata = parseJsonObject(metadataContent) - if (!metadata || extractString(metadata.parent_session_id)) { + const metadata = listedKiroMetadata(metadataContent) + if (!metadata) { return null } const accumulator = createAccumulator({ diff --git a/src/main/ai-vault/session-scanner-opencode-parser.ts b/src/main/ai-vault/session-scanner-opencode-parser.ts index 2bc0245bcb00..ee152cfe9b1a 100644 --- a/src/main/ai-vault/session-scanner-opencode-parser.ts +++ b/src/main/ai-vault/session-scanner-opencode-parser.ts @@ -24,6 +24,11 @@ import { tokenTotal } from './session-scanner-values' +/** The session's cwd; a scope walk reads it from the session file without its messages. */ +export function openCodeSessionCwd(record: Record): string | null { + return extractString(record.directory) +} + export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, @@ -36,7 +41,7 @@ export async function parseOpenCodeSessionFile( const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages }) accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.directory) + accumulator.cwd = openCodeSessionCwd(record) updateTimeline(accumulator, timeObjectValue(record.time, 'created')) updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) diff --git a/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts b/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts new file mode 100644 index 000000000000..49184403b6bd --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope-cwd-facts.test.ts @@ -0,0 +1,200 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access' +import { grokEncodedCwdDirName } from '../../shared/grok-session-paths' +import { + flushSessionParseCachePersist, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' +import { resetSessionParseCacheForTests } from './session-parse-cache-store' +import { scanAiVaultSessions } from './session-scanner' +import SyncDatabase from '../sqlite/sync-database' +import { SessionParseCacheDatabase } from './session-parse-cache-database' +import { parseAgentSessionFileCached } from './session-scanner-parse-cache' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const reads = vi.hoisted((): string[] => []) +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + wslGatedReadFile: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.wslGatedReadFile(...args) + }, + readTranscriptSlice: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.readTranscriptSlice(...args) + }, + openTranscriptReadStream: (...args: Parameters) => { + reads.push(String(args[0])) + return actual.openTranscriptReadStream(...args) + } + } +}) + +let tempRoots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + reads.length = 0 +}) + +it.each([false, true])('keeps cold authority with read failure=%s', async (failReads) => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-invalid-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const persistence = { filePath: join(root, 'parse-cache.json'), appVersion: 'test' } + initSessionParseCachePersistence(persistence) + const paths: string[] = [] + for (let index = 0; index < 8; index++) { + const path = await writeRollout( + roots.codexSessionsDir, + `old-${index}`, + index === 0 ? '/elsewhere' : '/repo', + index + 1 + ) + if (index === 0) { + const context = jsonLines([{ type: 'turn_context', payload: { cwd: '/repo' } }]) + await appendFile(path, `\n${context}\n`) + } + const { mtimeMs, size: sizeBytes, mtime } = await stat(path) + const parsed = await parseAgentSessionFileCached( + { + agent: 'codex', + file: { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() }, + codexHome: null + }, + 'linux' + ) + expect(parsed?.cwd).toBe('/repo') + paths.push(path) + } + await flushSessionParseCachePersist() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite')) + for (const [index, session] of ['{', '1', '[]', 'true', '"scalar"', 'null', 'null'].entries()) { + db.prepare('UPDATE rows SET session = ?, sidecar = ? WHERE path = ?').run( + session, + index === 5 ? '{' : null, + paths[index + 1] + ) + } + db.prepare( + 'INSERT INTO scope_cwds SELECT path, mtime_ms, size_bytes, platform, ?, ?, seen_at FROM rows WHERE path = ?' + ).run('{', '/elsewhere', paths[0]) + db.close() + for (let index = 0; index < 9; index++) { + await writeRollout(roots.codexSessionsDir, `busy-${index}`, '/other', 20) + } + initSessionParseCachePersistence(persistence) + const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds') + const ordinary = vi.spyOn(SessionParseCacheDatabase.prototype, 'read') + const prepare = SyncDatabase.prototype.prepare + const queries = vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + if (failReads && sql.startsWith('SELECT') && sql.includes('FROM rows WHERE path')) { + throw new Error('unavailable parsed channel') + } + return prepare.call(this, sql) + }) + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 8, + scopePaths: ['/repo'] + }) + expect( + result.sessions + .filter((row) => row.cwd === '/repo') + .map((row) => row.filePath) + .sort() + ).toEqual(paths.slice(0, failReads ? 8 : 7).sort()) + expect( + metadata.mock.calls.filter(([batch]) => batch.some((path) => paths.includes(path))) + ).toHaveLength(1) + expect(queries.mock.calls.filter(([sql]) => sql.includes('WHERE path IN'))).toHaveLength(2) + if (failReads) { + expect(ordinary.mock.calls.filter(([path]) => paths.includes(path))).toEqual([]) + } +}) + +async function writeGrokSession(sessionsDir: string, id: string, cwd: string, day: number) { + const dir = join(sessionsDir, grokEncodedCwdDirName(cwd) ?? 'unknown', id) + await mkdir(dir, { recursive: true }) + const timestamp = new Date(Date.UTC(2026, 9, day, 10)) + const summary = join(dir, 'summary.json') + await writeFile( + summary, + JSON.stringify({ + info: { id, cwd }, + created_at: timestamp.toISOString(), + updated_at: timestamp.toISOString() + }) + ) + await writeFile(join(dir, 'chat_history.jsonl'), jsonLines([{ type: 'user', content: id }])) + await utimes(summary, timestamp, timestamp) + return dir +} + +async function writeRollout(sessionsDir: string, id: string, cwd: string, day: number) { + const timestamp = new Date(Date.UTC(2026, 9, day, 10)) + const dir = join(sessionsDir, '2026', '10', String(day).padStart(2, '0')) + const path = join(dir, `rollout-2026-10-${String(day).padStart(2, '0')}T10-00-00-${id}.jsonl`) + await mkdir(dir, { recursive: true }) + await writeFile( + path, + jsonLines([ + { timestamp, type: 'session_meta', payload: { id, cwd } }, + { + timestamp, + type: 'response_item', + payload: { type: 'message', role: 'user', content: [{ type: 'text', text: id }] } + } + ]) + ) + await utimes(path, timestamp, timestamp) + return path +} + +it('settles out-of-scope transcripts once and reads none of them after a restart', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-cwd-facts-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const persistence = { filePath: join(root, 'user-data', 'parse-cache.json'), appVersion: 'test' } + initSessionParseCachePersistence(persistence) + const others: string[] = [] + for (const day of [15, 14, 13]) { + others.push(await writeGrokSession(roots.grokSessionsDir, `other-${day}`, '/other', day)) + } + const quiet = await writeGrokSession(roots.grokSessionsDir, 'repo-1', '/repo', 1) + const elsewhere = await writeRollout(roots.codexSessionsDir, 'other-2', '/other', 2) + const scan = () => + scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] }) + + const listed = (await scan()).sessions.map((session) => session.cwd) + expect(listed.filter((cwd) => cwd === '/repo')).toHaveLength(1) + // The walked Grok session was ruled out from summary.json, never its history. + expect(reads).not.toContain(join(others[2], 'chat_history.jsonl')) + + await flushSessionParseCachePersist() + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + initSessionParseCachePersistence(persistence) + reads.length = 0 + + const relisted = (await scan()).sessions.filter((session) => session.cwd === '/repo') + expect(relisted.map((session) => session.filePath)).toEqual([join(quiet, 'summary.json')]) + expect(reads.filter((path) => path.startsWith(root))).toEqual([]) + expect(reads).not.toContain(elsewhere) +}) diff --git a/src/main/ai-vault/session-scanner-scope-walk.test.ts b/src/main/ai-vault/session-scanner-scope-walk.test.ts index 81e3f730a978..a9f9dbec06c6 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.test.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.test.ts @@ -3,18 +3,29 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { truncateAiVaultListResult } from '../../shared/ai-vault-session-depth' +import type { AiVaultListResult } from '../../shared/ai-vault-types' import { parseSessionCandidates } from './session-scanner-candidate-parse' import { MAX_UNLISTED_ENTRIES, + scopeCwdStampOf, peekSessionParseCacheEntry, setSessionParseCacheColdTier, type PersistedSessionParseCacheEntry, storeSessionParseCacheEntry, + storeSessionScopeCwd, resetSessionParseCacheForTests } from './session-parse-cache-store' import { createSessionParseStats, parseAgentSessionFileCached } from './session-scanner-parse-cache' import { scanAiVaultSessions } from './session-scanner' -import { resetTranscriptHeadCwdCacheForTests } from './session-scope-cwd' +import * as eventLoop from '../../shared/event-loop-yield' +import { AI_VAULT_SCOPE_CWD_SOURCES } from './session-scope-cwd' +import { SessionParseCacheDatabase } from './session-parse-cache-database' +import SyncDatabase from '../sqlite/sync-database' +import { + initSessionParseCachePersistence, + flushSessionParseCachePersist, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' import { isolatedScanRoots, jsonLines, @@ -26,12 +37,85 @@ import { let tempRoots: string[] = [] afterEach(async () => { + vi.restoreAllMocks() + resetSessionParseCachePersistenceForTests() await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) tempRoots = [] - resetTranscriptHeadCwdCacheForTests() resetSessionParseCacheForTests() }) +it.each(['prefetch', 'read'] as const)('keeps an evicted parse from %s', async (boundary) => { + const root = await mkdtemp(join(tmpdir(), 'orca-scope-publication-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const path = join(roots.codexSessionsDir, 'rollout-published.jsonl') + await writeJsonlFile(path, [ + { type: 'session_meta', payload: { id: 'published', cwd: '/elsewhere' } }, + { type: 'turn_context', payload: { cwd: '/repo' } }, + { type: 'event_msg', payload: { type: 'user_message', message: 'Find me' } } + ]) + await utimes(path, 1, 1) + await writeRollout(roots.codexSessionsDir, 'newer', '/other', '15') + const { mtimeMs, size: sizeBytes, mtime } = await stat(path) + const file = { path, mtimeMs, sizeBytes, modifiedAt: mtime.toISOString() } + initSessionParseCachePersistence({ filePath: join(root, 'cache.json'), appVersion: 'test' }) + const metadata = vi.spyOn(SessionParseCacheDatabase.prototype, 'readScopeCwds') + const publish = async () => { + const parsed = await parseAgentSessionFileCached( + { agent: 'codex', file, codexHome: null }, + 'linux' + ) + expect(parsed?.cwd).toBe('/repo') + for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) { + storeSessionParseCacheEntry(`index-${index}`, { + mtimeMs: mtimeMs + 1, + sizeBytes: null, + platform: 'linux', + session: null, + resume: null + }) + } + if (boundary === 'read') { + await flushSessionParseCachePersist() + } + const db = new SyncDatabase(join(root, 'session-parse-cache.sqlite')) + expect(db.prepare('SELECT path FROM rows WHERE path = ?').get(path) !== undefined).toBe( + boundary === 'read' + ) + db.close() + metadata.mockClear() + } + if (boundary === 'prefetch') { + storeSessionScopeCwd(path, { mtimeMs, sizeBytes, platform: 'linux', cwd: '/elsewhere' }) + vi.spyOn(eventLoop, 'yieldToEventLoop').mockImplementationOnce(publish) + } else { + const source = AI_VAULT_SCOPE_CWD_SOURCES.codex + if (source.kind !== 'read') { + throw new Error('Codex must have a cheap reader') + } + const original = source.readCwd + vi.spyOn(source, 'readCwd').mockImplementation(async (candidate, signal) => { + const read = await original(candidate, signal) + if (candidate.file.path === path) { + await publish() + } + return read + }) + } + const result = await scanAiVaultSessions({ + ...roots, + platform: 'linux', + limit: 1, + scopePaths: ['/repo'] + }) + const found = result.sessions.filter((row) => row.filePath === path) + expect(found.map((row) => row.cwd)).toEqual(['/repo']) + expect( + metadata.mock.calls.filter(([paths]) => paths.length === 1 && paths[0] === path) + ).toHaveLength(1) + expect(await stat(path)).toMatchObject({ mtimeMs, size: sizeBytes }) +}) + async function writeRollout(sessionsDir: string, id: string, cwd: string, day: string) { const dir = join(sessionsDir, '2026', '10', day) await mkdir(dir, { recursive: true }) @@ -149,11 +233,12 @@ it('admits the newest scoped activity when copied Hermes files have newer mtimes .sessions.filter((row) => row.cwd === '/repo') .map((row) => row.sessionId) ).toEqual(['hermes-16']) - expect( - truncateAiVaultListResult(await scan(2), 1, ['/repo']) - .sessions.filter((row) => row.cwd === '/repo') - .map((row) => row.sessionId) - ).toEqual(['hermes-16']) + const deeper = await scan(2) + const scopedIds = (rows: AiVaultListResult) => + rows.sessions.filter((row) => row.cwd === '/repo').map((row) => row.sessionId) + expect(scopedIds(deeper)[0]).toBe('hermes-16') + // A deeper cached scan serving depth 1 keeps every scoped row it found. + expect(scopedIds(truncateAiVaultListResult(deeper, 1, ['/repo']))).toEqual(scopedIds(deeper)) }) it('delivers cancellation while traversing unchanged cached candidates', async () => { @@ -214,6 +299,16 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn const read = vi.fn((key: string) => rows.get(key)) setSessionParseCacheColdTier({ read, + readScopeCwds: (paths) => ({ + found: new Map( + paths.flatMap((key) => { + const row = rows.get(key) + return row === undefined ? [] : [[key, { parsed: scopeCwdStampOf(row) }] as const] + }) + ), + parsed: true, + read: true + }), stored: (key, row) => rows.set(key, row), deleted: (key) => rows.delete(key) }) diff --git a/src/main/ai-vault/session-scanner-scope-walk.ts b/src/main/ai-vault/session-scanner-scope-walk.ts index 93afeaf74594..6d6248e679e6 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.ts @@ -2,7 +2,16 @@ import { createAiVaultScopeCwdMatcher } from '../../shared/ai-vault-scope-cwd-ma import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { noteAiVaultScanReading } from './ai-vault-scan-progress' -import { peekSessionParseCacheEntry } from './session-parse-cache-store' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { + lookupSessionScopeCwds, + clearSessionScopeCwdKnowledge, + pruneSessionScopeCwds, + resolveSessionScopeCwd, + storeSessionScopeCwd, + type SessionScopeCwdStamp +} from './session-parse-cache-store' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { parseSessionCandidates } from './session-scanner-candidate-parse' import { sessionSortTime } from './session-scanner-accumulator' @@ -15,12 +24,7 @@ import type { SessionFileCandidate, SessionFileDiscovery } from './session-scanner-types' -import { - AI_VAULT_SCOPE_CWD_SOURCES, - pruneTranscriptHeadCwdCache, - readTranscriptHeadCwd, - scopeWalksAgentFiles -} from './session-scope-cwd' +import { AI_VAULT_SCOPE_CWD_SOURCES, scopeWalksAgentFiles } from './session-scope-cwd' import { sidecarUnchanged } from './session-sidecar-stat' import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates' @@ -63,7 +67,7 @@ export async function scanInScopeSessions(args: ScopeScanArgs): Promise ({ agent: layout.agent, file, codexHome: null }))) } - const walked = await walkInScopeSessions(args, scopePaths) + const walked = await walkInScopeSessions(args, scopePaths).finally(clearSessionScopeCwdKnowledge) if (candidates.length === 0) { return walked } @@ -93,6 +97,7 @@ async function walkInScopeSessions( const signal = args.options.signal const inScope = createAiVaultScopeCwdMatcher(scopePaths) const candidates = walkCandidates(args, scopePaths) + await lookupScopeCwds(candidates, signal) const sessions = await parseSessionCandidates({ candidates, limit: args.limit, @@ -130,7 +135,7 @@ function walkCandidates( } all.push(...discoveryFileCandidates(discovery, files, args.options)) } - pruneTranscriptHeadCwdCache(seenPaths) + pruneSessionScopeCwds(seenPaths) // Aliases collapse with the listed files still present, so a listed rollout's // hardlink in another Codex home is not parsed again. return prioritizeAntigravityTranscriptCandidates( @@ -141,7 +146,29 @@ function walkCandidates( ) } -/** False only when a current parse or the transcript's opening records put it out of scope. */ +// Per lookup batch: one database round trip, then a turn for the event loop. +const SCOPE_LOOKUP_BATCH = 500 + +async function lookupScopeCwds( + candidates: readonly SessionFileCandidate[], + signal: AbortSignal | undefined +): Promise { + // The Antigravity fold has no cwd until the per-scan history join runs. + const paths = candidates + .filter((candidate) => !candidate.antigravityHistoryPath) + .map((candidate) => candidate.file.path) + for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_BATCH) { + throwIfAiVaultScanCancelled(signal) + lookupSessionScopeCwds(paths.slice(start, start + SCOPE_LOOKUP_BATCH)) + await yieldToEventLoop() + } +} + +/** + * False only when this transcript version's cwd is known to be out of scope: a + * parse row's cwd first, then one read without a parse. A read the agent can + * settle is stored, so the next scan of an unchanged file reads nothing. + */ async function mayBeInScope( candidate: SessionFileCandidate, inScope: ReturnType, @@ -149,24 +176,41 @@ async function mayBeInScope( signal: AbortSignal | undefined ): Promise { const { file } = candidate - const entry = peekSessionParseCacheEntry(file.path) - if ( - // The Antigravity fold has no cwd until the per-scan history join runs. - !candidate.antigravityHistoryPath && - entry !== undefined && - entry.platform === platform && - entry.mtimeMs === file.mtimeMs && - (entry.sizeBytes === null || - file.sizeBytes === undefined || - entry.sizeBytes === file.sizeBytes) && - sidecarUnchanged(entry.sidecar, file.sidecar) - ) { - return entry.session !== null && inScope(entry.session.cwd, file.path) + if (candidate.antigravityHistoryPath) { + return true } + const resolve = () => + resolveSessionScopeCwd(file.path, (stamp) => stampMatchesFile(stamp, file, platform)) + let known = resolve() const source = AI_VAULT_SCOPE_CWD_SOURCES[candidate.agent] - if (source.kind !== 'head') { - return true + if (known === 'read' && source.kind === 'read') { + const read = await source.readCwd(candidate, signal) + throwIfAiVaultScanCancelled(signal) + if (read !== null) { + storeSessionScopeCwd(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform, + cwd: read.cwd, + ...(file.sidecar === undefined ? {} : { sidecar: file.sidecar }) + }) + } + known = resolve() } - const cwd = await readTranscriptHeadCwd(file, source, signal) - return cwd === null || inScope(cwd, file.path) + return typeof known === 'string' || (known.cwd !== null && inScope(known.cwd, file.path)) +} + +function stampMatchesFile( + stamp: SessionScopeCwdStamp, + file: FileWithMtime, + platform: NodeJS.Platform +): boolean { + return ( + stamp.platform === platform && + stamp.mtimeMs === file.mtimeMs && + (stamp.sizeBytes === null || + file.sizeBytes === undefined || + stamp.sizeBytes === file.sizeBytes) && + sidecarUnchanged(stamp.sidecar, file.sidecar) + ) } diff --git a/src/main/ai-vault/session-scanner-service-cancellation.test.ts b/src/main/ai-vault/session-scanner-service-cancellation.test.ts index 8863006017e0..925259000147 100644 --- a/src/main/ai-vault/session-scanner-service-cancellation.test.ts +++ b/src/main/ai-vault/session-scanner-service-cancellation.test.ts @@ -32,7 +32,7 @@ const disconnect = vi.fn() let restoreProcess = (): void => undefined function emit(message: AiVaultServiceParentMessage): void { - process.emit('message', message) + process.emit('message', message, undefined) } function scan(id: number): void { diff --git a/src/main/ai-vault/session-scope-cwd.ts b/src/main/ai-vault/session-scope-cwd.ts index fad3287dc145..8d0196d6b54e 100644 --- a/src/main/ai-vault/session-scope-cwd.ts +++ b/src/main/ai-vault/session-scope-cwd.ts @@ -1,36 +1,101 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' -import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' +import { readTranscriptSlice, wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' import { CLAUDE_CWD_BUCKET_LAYOUT, CODEBUDDY_CWD_BUCKET_LAYOUT, PI_CWD_BUCKET_LAYOUT, type CwdBucketLayout } from './session-cwd-bucket-layouts' -import type { FileWithMtime } from './session-scanner-types' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' import { asRecord } from './session-scanner-record-value' import { extractString, parseJsonObject } from './session-scanner-values' +import { clineManifestCwd } from './session-scanner-cline-parser' +import { cursorChatMetaUnavailable, readCursorChatMeta } from './session-scanner-cursor-chat-meta' +import { rovoSessionCwd } from './session-scanner-graph-parsers' +import { grokSessionCwd } from './session-scanner-grok-parser' +import { listedKiroMetadata } from './session-scanner-kiro-parser' +import { openCodeSessionCwd } from './session-scanner-opencode-parser' type HeadRecord = Record +/** A definitive cwd (null: the session has none), or null when the read could not settle it. */ +export type ScopeCwdRead = { cwd: string | null } | null + /** * How Workspace and Project views find a transcript's cwd without a full parse, * so an agent's older in-scope sessions are selected before the recency cap. * - bucket: the agent keeps one directory per cwd. - * - head: the cwd sits in the transcript's opening records. + * - read: a small read settles it. A settled cwd is stored per transcript + * version, so later scans read nothing; a parse row always outranks it. * - parse: only the (cached) full parse knows it. * - none: the agent never records a cwd, so no session can belong to a scope. */ export type SessionScopeCwdSource = | { kind: 'bucket'; layout: CwdBucketLayout } - | { kind: 'head'; cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number } + | { + kind: 'read' + readCwd: (candidate: SessionFileCandidate, signal?: AbortSignal) => Promise + } | { kind: 'parse' } | { kind: 'none' } const PARSE: SessionScopeCwdSource = { kind: 'parse' } + +/** The cwd in the transcript's opening records; a later record can still move it, which a parse row then corrects. */ +function head( + cwdOf: (record: HeadRecord) => unknown, + firstReadBytes?: number +): SessionScopeCwdSource { + return { + kind: 'read', + readCwd: async ({ file }, signal) => { + const cwd = await readTranscriptHeadCwd(file, { cwdOf, firstReadBytes }, signal) + return cwd === null ? null : { cwd } + } + } +} + +function document(cwdOf: (content: string) => string | null): SessionScopeCwdSource { + return { kind: 'read', readCwd: ({ file }, signal) => readDocumentCwd(file, cwdOf, signal) } +} + +/** The parser's own cwd from the small file it reads first; `cwdOf` throwing is the parser's failure too. */ +async function readDocumentCwd( + file: FileWithMtime, + cwdOf: (content: string) => string | null, + signal?: AbortSignal +): Promise { + const content = await settledRead( + () => wslGatedReadFile(file.path, 'utf-8', 'scan', signal), + signal + ) + if (content === null) { + return null + } + try { + return { cwd: cwdOf(content) } + } catch { + return null + } +} + +function jsonRecord(content: string): Record | null { + return asRecord(JSON.parse(content) as unknown) +} + // Pi-family header: `{"type":"session","cwd":...}`. -const SESSION_HEADER: SessionScopeCwdSource = { - kind: 'head', - cwdOf: (record) => (record.type === 'session' ? record.cwd : undefined) +const SESSION_HEADER = head((record) => (record.type === 'session' ? record.cwd : undefined)) + +// Cursor transcripts never record a cwd; the session's comes from its meta.json. +const CURSOR: SessionScopeCwdSource = { + kind: 'read', + readCwd: async ({ file }, signal) => { + const meta = await settledRead(() => readCursorChatMeta(file.path, signal), signal) + if (meta) { + return { cwd: meta.cwd } + } + return file.sidecar === 'none' && !cursorChatMetaUnavailable(file.path) ? { cwd: null } : null + } } // Keyed by every agent, so a new agent cannot list without saying how its scope is found. @@ -38,38 +103,58 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), - // Its first record carries the base instructions (about 20 KB): one read, not two. - firstReadBytes: 64 * 1024 - }, - qoder: { kind: 'head', cwdOf: (record) => record.cwd }, + // Its first record carries the base instructions (about 20 KB): one read, not two. + codex: head( + (record) => (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), + 64 * 1024 + ), + qoder: head((record) => record.cwd), omp: SESSION_HEADER, 'prime-agent': SESSION_HEADER, openclaw: SESSION_HEADER, - droid: { - kind: 'head', - cwdOf: (record) => (record.type === 'session_start' ? record.cwd : undefined) - }, + droid: head((record) => (record.type === 'session_start' ? record.cwd : undefined)), // Gemini transcripts carry no cwd at all. gemini: { kind: 'none' }, - // Small per-session documents, a cwd recorded mid-file, a sidecar, or a database row. + // Their parsers take the cwd from the small file discovery found, before any history. + grok: document((content) => { + const summary = jsonRecord(content) + return summary ? grokSessionCwd(summary) : null + }), + rovo: document((content) => { + const metadata = jsonRecord(content) + return metadata ? rovoSessionCwd(metadata) : null + }), + cline: document(clineManifestCwd), + kiro: document((content) => { + const metadata = listedKiroMetadata(content) + return metadata ? extractString(metadata.cwd) : null + }), + cursor: CURSOR, + // Legacy session files; database rows (`#`) are already filtered by directory in SQL. + opencode: { + kind: 'read', + readCwd: async ({ file }, signal) => + file.path.includes('#') + ? null + : readDocumentCwd( + file, + (content) => { + const record = jsonRecord(content) + return record ? openCodeSessionCwd(record) : null + }, + signal + ) + }, + // A cwd recorded mid-file, a sidecar join, a shared index, or a database row. hermes: PARSE, - cursor: PARSE, antigravity: PARSE, - rovo: PARSE, copilot: PARSE, - opencode: PARSE, opencode2: PARSE, zcode: PARSE, - grok: PARSE, devin: PARSE, - cline: PARSE, kimi: PARSE, muse: PARSE, - jcode: PARSE, - kiro: PARSE + jcode: PARSE } /** Whether a scan with these scope paths walks this agent's files past the recency cap. */ @@ -78,7 +163,7 @@ export function scopeWalksAgentFiles( scopePaths: readonly string[] | undefined ): boolean { const kind = AI_VAULT_SCOPE_CWD_SOURCES[agent].kind - return (scopePaths?.length ?? 0) > 0 && (kind === 'head' || kind === 'parse') + return (scopePaths?.length ?? 0) > 0 && (kind === 'read' || kind === 'parse') } const HEAD_READ_BYTES = 8 * 1024 @@ -94,10 +179,6 @@ export async function readTranscriptHeadCwd( source: { cwdOf: (record: HeadRecord) => unknown; firstReadBytes?: number }, signal?: AbortSignal ): Promise { - const cached = headCwdCache.get(file.path) - if (cached && cached.mtimeMs === file.mtimeMs && cached.sizeBytes === file.sizeBytes) { - return cached.cwd - } const { cwdOf } = source let cwd: string | null = null const first = source.firstReadBytes ?? HEAD_READ_BYTES @@ -121,13 +202,6 @@ export async function readTranscriptHeadCwd( break } } - if (cwd !== null) { - headCwdCache.set(file.path, { - mtimeMs: file.mtimeMs, - sizeBytes: file.sizeBytes, - cwd: internCwd(cwd) - }) - } return cwd } @@ -145,39 +219,14 @@ function firstHeadCwd( return null } -// In memory only: a restart re-reads heads, and parsed files answer from the parse cache. -const headCwdCache = new Map< - string, - { mtimeMs: number; sizeBytes: number | undefined; cwd: string } ->() -// Thousands of transcripts share a handful of cwds. -const cwdStrings = new Map() - -function internCwd(cwd: string): string { - const existing = cwdStrings.get(cwd) - if (existing !== undefined) { - return existing - } - cwdStrings.set(cwd, cwd) - return cwd -} - -/** Drops cached heads for transcripts the latest scoped scan no longer discovered. */ -export function pruneTranscriptHeadCwdCache(discoveredPaths: ReadonlySet): void { - for (const path of headCwdCache.keys()) { - if (!discoveredPaths.has(path)) { - headCwdCache.delete(path) +/** A read's value, or null when it failed; an abort still propagates. */ +async function settledRead(read: () => Promise, signal?: AbortSignal): Promise { + try { + return await read() + } catch (error) { + if (signal?.aborted) { + throw error } + return null } - const liveCwds = new Set([...headCwdCache.values()].map((entry) => entry.cwd)) - for (const cwd of cwdStrings.keys()) { - if (!liveCwds.has(cwd)) { - cwdStrings.delete(cwd) - } - } -} - -export function resetTranscriptHeadCwdCacheForTests(): void { - headCwdCache.clear() - cwdStrings.clear() } diff --git a/src/main/ai-vault/session-scope-cwds-table.ts b/src/main/ai-vault/session-scope-cwds-table.ts new file mode 100644 index 000000000000..b292e8be7ca9 --- /dev/null +++ b/src/main/ai-vault/session-scope-cwds-table.ts @@ -0,0 +1,126 @@ +// The scope_cwds table of the session parse cache database: a transcript +// version's cwd learned without a parse, read beside each parse row's own cwd. +import type SyncDatabase from '../sqlite/sync-database' +import { parsePersistedSidecar } from './session-sidecar-stat' +import type { SessionScopeCwdLookup, SessionScopeCwdStamp } from './session-parse-cache-store' + +// A transcript's cwd learned without a parse (its opening records, or a small +// file its parser reads it from). Versioned apart from rows, so a reader fix +// resets only these. +const SCOPE_CWDS_VERSION = '1' +const SCOPE_CWDS_SQL = ` +CREATE TABLE IF NOT EXISTS scope_cwds( + path TEXT PRIMARY KEY, + mtime_ms REAL NOT NULL, + size_bytes INTEGER, + platform TEXT NOT NULL, + sidecar TEXT, + cwd TEXT, + seen_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS scope_cwds_seen_at ON scope_cwds(seen_at); +` +// Keeps each IN list well under SQLite's bound-parameter limit. +const SCOPE_LOOKUP_CHUNK = 500 +const VALID_SESSION = + "CASE WHEN json_valid(session) THEN json_type(session) IN ('object','null') ELSE 0 END" + +export function readScopeCwdRows( + db: SyncDatabase, + paths: readonly string[], + channel: 'parsed' | 'read' +): Map { + const found = new Map() + for (let start = 0; start < paths.length; start += SCOPE_LOOKUP_CHUNK) { + const chunk = paths.slice(start, start + SCOPE_LOOKUP_CHUNK) + const marks = chunk.map(() => '?').join(',') + for (const [table, key, cwdColumn] of [ + ['rows', 'parsed', `CASE WHEN ${VALID_SESSION} THEN json_extract(session, '$.cwd') END`], + ['scope_cwds', 'read', 'cwd'] + ] as const) { + if (key !== channel) { + continue + } + const rows = db + .prepare( + `SELECT path, mtime_ms, size_bytes, platform, sidecar, ${cwdColumn} AS cwd FROM ${table} WHERE path IN (${marks}) ${key === 'parsed' ? `AND (${VALID_SESSION})` : ''}` + ) + .all(...chunk) + for (const row of rows) { + const stamp = parseScopeCwdStamp(row) + if (stamp && typeof row.path === 'string') { + found.set(row.path, { ...found.get(row.path), [key]: stamp }) + } + } + } + } + return found +} + +export function writeScopeCwdRows( + db: SyncDatabase, + rows: Iterable<[string, SessionScopeCwdStamp]>, + seenAt: number +): void { + const insert = db.prepare( + 'INSERT OR REPLACE INTO scope_cwds(path, mtime_ms, size_bytes, platform, sidecar, cwd, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ) + db.exec('BEGIN IMMEDIATE') + try { + for (const [path, stamp] of rows) { + insert.run( + path, + stamp.mtimeMs, + stamp.sizeBytes, + stamp.platform, + stamp.sidecar === undefined ? null : JSON.stringify(stamp.sidecar), + stamp.cwd, + seenAt + ) + } + db.exec('COMMIT') + } catch (error) { + if (db.isTransaction) { + db.exec('ROLLBACK') + } + throw error + } +} + +export function ensureScopeCwdsTable(db: SyncDatabase): void { + const current = db.prepare("SELECT value FROM meta WHERE key = 'scope_cwds_version'").get() + if (current?.value !== SCOPE_CWDS_VERSION) { + db.exec('DROP TABLE IF EXISTS scope_cwds') + } + db.exec(SCOPE_CWDS_SQL) + db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES ('scope_cwds_version', ?)").run( + SCOPE_CWDS_VERSION + ) +} + +function parseScopeCwdStamp(row: Record): SessionScopeCwdStamp | null { + const { mtime_ms: mtimeMs, size_bytes: sizeBytes, platform, cwd } = row + if ( + typeof mtimeMs !== 'number' || + (sizeBytes !== null && typeof sizeBytes !== 'number') || + typeof platform !== 'string' || + (cwd !== null && typeof cwd !== 'string') + ) { + return null + } + let sidecar: ReturnType + try { + sidecar = + typeof row.sidecar === 'string' ? parsePersistedSidecar(JSON.parse(row.sidecar)) : undefined + } catch { + return null + } + return { + mtimeMs, + sizeBytes, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only ever compared for equality with process.platform, so an unknown value is a miss, not a misuse. + platform: platform as NodeJS.Platform, + cwd, + ...(sidecar === undefined ? {} : { sidecar }) + } +} diff --git a/src/main/ai-vault/session-sidecar-stat.ts b/src/main/ai-vault/session-sidecar-stat.ts index e176da342e26..f29ce43f695c 100644 --- a/src/main/ai-vault/session-sidecar-stat.ts +++ b/src/main/ai-vault/session-sidecar-stat.ts @@ -47,3 +47,23 @@ export function sidecarUnchanged( entry.sizeBytes === observed.sizeBytes ) } + +// Why: added after SCHEMA_VERSION 2 shipped, so a row an older build wrote has +// no such field. Absent (or unreadable) means unknown, which costs one re-parse +// of the rows that have a sibling and nothing at all for the rest. +export function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined { + if (value === 'none' || value === 'unknown') { + return value + } + if (!isRecord(value)) { + return undefined + } + const { path, mtimeMs, sizeBytes } = value + return typeof path === 'string' && typeof mtimeMs === 'number' && typeof sizeBytes === 'number' + ? { path, mtimeMs, sizeBytes } + : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/antigravity/antigravity-chat-sign-in.test.ts b/src/main/antigravity/antigravity-chat-sign-in.test.ts index f6486f20249a..bbc07da96ae4 100644 --- a/src/main/antigravity/antigravity-chat-sign-in.test.ts +++ b/src/main/antigravity/antigravity-chat-sign-in.test.ts @@ -11,6 +11,8 @@ import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-chi import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' import { createAcpAgentConnection, type AcpAgentConnection } from '../acp/acp-agent-connection' import { ANTIGRAVITY_LAUNCH_SPEC } from '../acp/acp-launch-specs' +import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { prepareAcpStructuredLaunch } from '../acp/acp-structured-launch-preparation' import type { ConnectAcpAgent } from '../acp/acp-structured-connection' import { AcpScriptedAgent, deferred } from '../acp/acp-scripted-agent.test-support' @@ -84,6 +86,7 @@ async function signInAgainst( configure?: (connection: AcpAgentConnection) => void stream?: 'stdout' | 'stderr' metaCwd?: string + sessionResult?: Record } = {} ) { vi.spyOn(ANTIGRAVITY_LAUNCH_SPEC.managedRuntime, 'pin').mockReturnValue(PIN) @@ -105,7 +108,7 @@ async function signInAgainst( await mkdir(join(folder, 'antigravity-acp', 'conversations'), { recursive: true }) await writeFile(sessionFile(folder, '.db'), '') await writeFile(sessionFile(folder, '.meta'), JSON.stringify({ cwd: options.metaCwd ?? cwd })) - agent.reply(frame, { sessionId: SESSION }) + agent.reply(frame, { ...options.sessionResult, sessionId: SESSION }) }) }) const child = fakeServer(agent, stderr) @@ -227,6 +230,44 @@ it("shows the helper's link, reads signed in only once a session starts, then re expect(frames).toHaveLength(seen) }) +it("saves the checking session's models as the chats' catalog, for the first chat's picker", async () => { + // The model option from a recorded Antigravity 1.3.0 `session/new`. + const { folder, flowId, finishBrowser, openSession } = await signInAgainst( + () => helperOutput(GOOGLE_LINK), + { + sessionResult: { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'gemini-3.8-flash-high', + options: [ + { value: 'gemini-3.8-flash-high', name: 'Gemini 3.8 Flash (High)' }, + { value: 'gemini-3.8-flash-low', name: 'Gemini 3.8 Flash (Low)' } + ] + } + ] + } + } + ) + expect(await stateAfter(flowId, 'starting')).toMatchObject({ phase: 'waiting' }) + finishBrowser() + expect(await stateAfter(flowId, 'waiting')).toEqual({ flowId, phase: 'verifying' }) + openSession() + expect(await stateAfter(flowId, 'verifying')).toEqual({ flowId, phase: 'signedIn' }) + const fingerprint = agentModelCatalogFingerprint({ + agent: 'antigravity', + accountHome: { variable: 'GEMINI_HOME', path: folder }, + wslDistro: null + }) + expect(agentModelCatalogStore.get(fingerprint)?.live?.models.map((model) => model.id)).toEqual([ + 'gemini-3.8-flash-high', + 'gemini-3.8-flash-low' + ]) +}) + it('shows another client the sign-in without its link, and refuses it every action', async () => { const { flowId, host } = await signInAgainst(printed(GOOGLE_LINK)) expect(await stateAfter(flowId, 'starting')).toMatchObject({ phase: 'waiting' }) diff --git a/src/main/antigravity/antigravity-chat-sign-in.ts b/src/main/antigravity/antigravity-chat-sign-in.ts index 42844a2906d4..78519a03d4b7 100644 --- a/src/main/antigravity/antigravity-chat-sign-in.ts +++ b/src/main/antigravity/antigravity-chat-sign-in.ts @@ -18,6 +18,7 @@ import type { ConnectAcpAgent } from '../acp/acp-structured-connection' import { resolveAcpLaunchInvocation } from '../acp/acp-structured-launch-resolution' import { prepareAcpLaunch } from '../acp/acp-structured-launch-preparation' import { AcpAgentError, AcpConnectionClosedError } from '../acp/acp-errors' +import { saveSignInModels } from './antigravity-sign-in-models' import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { structuredAgentHostLaunchSources } from '../runtime/structured-agent-launch-support' import { @@ -219,6 +220,7 @@ async function signIn(current: Flow, host: AntigravityChatSignInHost): Promise 0) { + agentModelCatalogStore.recordSuccess( + agentModelCatalogFingerprint({ agent: spec.agent, accountHome, wslDistro: null }), + spec.agent, + { models, origin: 'live-session' }, + 'live' + ) + } +} diff --git a/src/main/automations/automation-worker-durability.test.ts b/src/main/automations/automation-worker-durability.test.ts index 1cd77cd8518c..1bf497a71a4e 100644 --- a/src/main/automations/automation-worker-durability.test.ts +++ b/src/main/automations/automation-worker-durability.test.ts @@ -210,7 +210,7 @@ describe('automation background writer durability', () => { try { const pending = service.runNow(automation.id) await acknowledgement.blocked - store.updateRepo('repo-local', { executionHostId: 'runtime:other-host' }) + store.updateRepo({ id: 'repo-local' }, { executionHostId: 'runtime:other-host' }) acknowledgement.release() await expect(pending).resolves.toMatchObject({ status: 'skipped_unavailable' }) expect(dispatcher).not.toHaveBeenCalled() diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts index 31f5a191ede4..9c09d1f5fc19 100644 --- a/src/main/browser/agent-browser-bridge-execution.ts +++ b/src/main/browser/agent-browser-bridge-execution.ts @@ -11,6 +11,7 @@ import { } from './agent-browser-bridge-process' import { translateResult } from './agent-browser-bridge-result' import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs' +import { assertRemoteCliBrowserAccess } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' import { canSkipAgentBrowserSessionReset } from './agent-browser-session-reset' import { @@ -83,6 +84,8 @@ export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs throw this.createPageUnavailableError(sessionName) } + // Why: a command can spawn several helpers in turn; none may start after a host's revocation. + assertRemoteCliBrowserAccess(session.webContentsId) this.reinitializeIfDaemonIdledOut(sessionName, session) session.lastCommandAt = Date.now() diff --git a/src/main/browser/agent-browser-bridge-host-revocation.test.ts b/src/main/browser/agent-browser-bridge-host-revocation.test.ts new file mode 100644 index 000000000000..c1a929244313 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-host-revocation.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = + vi.hoisted(() => ({ + execFileMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + existsSyncMock: vi.fn(() => false), + readFileSyncMock: vi.fn(() => Buffer.from('')), + stdinWrites: new Array() + })) + +vi.mock('child_process', () => ({ execFile: execFileMock })) +vi.mock('fs', () => ({ + existsSync: existsSyncMock, + readFileSync: readFileSyncMock, + accessSync: vi.fn(), + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) +vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' })) +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false }, + webContents: { fromId: webContentsFromIdMock } +})) +const { CdpWsProxyMock } = vi.hoisted(() => { + const instances: unknown[] = [] + const MockClass = vi.fn().mockImplementation(function ( + this: Record, + _wc: unknown + ) { + this._wc = _wc + this.start = vi.fn(async () => 'ws://127.0.0.1:9222') + this.stop = vi.fn(async () => {}) + this.getPort = vi.fn(() => 9222) + instances.push(this) + }) + return { CdpWsProxyMock: Object.assign(MockClass, { instances }) } +}) +vi.mock('./cdp-ws-proxy', () => ({ CdpWsProxy: CdpWsProxyMock })) + +import { AgentBrowserBridge } from './agent-browser-bridge' +import { + captureRemoteCliBrowserScope, + runInRemoteCliBrowserScope +} from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { + assertRemoteCliPageAccess, + runUnderRemoteCliPageAdmission +} from './remote-cli-page-admission' +import { + createFakeAgentBrowserChild, + mockBrowserManager, + mockWebContents, + overrideBridgeWebContentsLookup, + resetAgentBrowserBridgeMocks, + type ExecFileCallback +} from './agent-browser-bridge-test-harness' + +overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock) + +const WORKSPACE = 'repo-host::/srv/app' + +/** A helper step reaching the page's CDP proxy now. */ +function assertHelperStepAdmitted(): void { + runUnderRemoteCliPageAdmission(100, () => assertRemoteCliPageAccess(100)) +} + +function dispatchedMouseEvents(wc: ReturnType): unknown[] { + return wc.debugger.sendCommand.mock.calls.filter((call) => call[0] === 'Input.dispatchMouseEvent') +} + +describe('AgentBrowserBridge under a host revocation', () => { + let bridge: AgentBrowserBridge + + beforeEach(() => { + resetAgentBrowserBridgeMocks({ + webContentsFromIdMock, + existsSyncMock, + readFileSyncMock, + stdinWrites, + cdpWsProxyInstances: CdpWsProxyMock.instances + }) + bridge = new AgentBrowserBridge(mockBrowserManager()) + bridge.setActiveTab(100) + }) + + it('never presses the mouse once the host is revoked during a running click', async () => { + const wc = mockWebContents(100) + let answerHitTest = (): void => {} + wc.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Runtime.evaluate') { + await new Promise((resolve) => { + answerHitTest = resolve + }) + return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } } + } + return {} + }) + webContentsFromIdMock.mockReturnValue(wc) + const revoked = new AbortController() + + const click = runInRemoteCliBrowserScope( + WORKSPACE, + () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18), + revoked.signal + ) + await vi.waitFor(() => + expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Runtime.evaluate', expect.anything()) + ) + revoked.abort() + answerHitTest() + + await expect(click).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + expect(dispatchedMouseEvents(wc)).toHaveLength(0) + }) + + it('holds the page against a running helper click once the host is revoked', async () => { + const helperCallbacks: ExecFileCallback[] = [] + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + if (args.includes('click')) { + helperCallbacks.push(cb) + } else { + cb(null, JSON.stringify({ success: true, data: null }), '') + } + return createFakeAgentBrowserChild({ stdin: { on: vi.fn(), end: vi.fn() } }) + } + ) + const revoked = new AbortController() + + const click = runInRemoteCliBrowserScope( + WORKSPACE, + () => bridge.click('@e1', undefined, 'tab-1'), + revoked.signal + ) + await vi.waitFor(() => expect(helperCallbacks).toHaveLength(1)) + expect(() => assertHelperStepAdmitted()).not.toThrow() + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every instance the hoisted mock constructs has a vi.fn stop. + const proxy = CdpWsProxyMock.instances.at(-1) as { stop: ReturnType } + let heldWhenRetired = false + proxy.stop.mockImplementation(async () => { + try { + assertHelperStepAdmitted() + } catch { + heldWhenRetired = true + } + }) + revoked.abort() + // The helper's next CDP step reaches the page's proxy and is refused there. + expect(() => assertHelperStepAdmitted()).toThrow('stopped sharing') + // Retired on the revocation itself, not when the helper's client times out. + await vi.waitFor(() => expect(proxy.stop).toHaveBeenCalled()) + helperCallbacks[0]!( + null, + JSON.stringify({ success: false, error: 'The Orca desktop stopped sharing its browser' }), + '' + ) + + await expect(click).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + expect(() => assertHelperStepAdmitted()).not.toThrow() + // The helper's daemon outlives its client, so it is retired before the page is let go. + expect(heldWhenRetired).toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['close']), + expect.anything(), + expect.anything() + ) + }) + + it('still runs a desktop-local command queued behind a host command that was revoked', async () => { + const wc = mockWebContents(100) + let answerHitTest = (): void => {} + wc.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Runtime.evaluate') { + await new Promise((resolve) => { + answerHitTest = resolve + }) + return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } } + } + return {} + }) + webContentsFromIdMock.mockReturnValue(wc) + const revoked = new AbortController() + const hostClick = runInRemoteCliBrowserScope( + WORKSPACE, + () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18), + revoked.signal + ) + hostClick.catch(() => {}) + await vi.waitFor(() => + expect(wc.debugger.sendCommand).toHaveBeenCalledWith('Runtime.evaluate', expect.anything()) + ) + + const localMove = bridge.mouseMove(5, 6, undefined, 'tab-1') + revoked.abort() + answerHitTest() + + await expect(hostClick).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + await expect(localMove).resolves.toBeDefined() + expect(dispatchedMouseEvents(wc)).toEqual([ + ['Input.dispatchMouseEvent', expect.objectContaining({ type: 'mouseMoved', x: 5, y: 6 })] + ]) + }) + + it('never hit-tests (which can activate the element) once revoked during the focus wait', async () => { + let grantFocus = (): void => {} + const release = vi.fn() + const guardUserFocusForAgentInput = vi.fn( + () => + new Promise<() => void>((resolve) => { + grantFocus = () => resolve(release) + }) + ) + bridge = new AgentBrowserBridge( + mockBrowserManager(undefined, undefined, { guardUserFocusForAgentInput }) + ) + bridge.setActiveTab(100) + const wc = mockWebContents(100) + wc.getType.mockReturnValue('webview') + wc.debugger.sendCommand.mockResolvedValue({}) + webContentsFromIdMock.mockReturnValue(wc) + const revoked = new AbortController() + + const click = runInRemoteCliBrowserScope( + WORKSPACE, + () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18), + revoked.signal + ) + await vi.waitFor(() => expect(guardUserFocusForAgentInput).toHaveBeenCalled()) + revoked.abort() + grantFocus() + + await expect(click).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + expect(wc.debugger.sendCommand).not.toHaveBeenCalledWith('Runtime.evaluate', expect.anything()) + expect(dispatchedMouseEvents(wc)).toHaveLength(0) + expect(release).toHaveBeenCalled() + }) + + it('lets go off the page of a press that landed just before the host was revoked', async () => { + const wc = mockWebContents(100) + const revoked = new AbortController() + wc.debugger.sendCommand.mockImplementation(async (method: string, params?: unknown) => { + if (method === 'Runtime.evaluate') { + return { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } } + } + if ( + typeof params === 'object' && + params !== null && + 'type' in params && + params.type === 'mousePressed' + ) { + revoked.abort() + } + return {} + }) + webContentsFromIdMock.mockReturnValue(wc) + + const click = runInRemoteCliBrowserScope( + WORKSPACE, + () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1', 18), + revoked.signal + ) + + await expect(click).rejects.toMatchObject({ code: 'desktop_browser_bridge_unavailable' }) + expect(dispatchedMouseEvents(wc)).toEqual([ + ['Input.dispatchMouseEvent', expect.objectContaining({ type: 'mousePressed', x: 10 })], + ['Input.dispatchMouseEvent', expect.objectContaining({ type: 'mouseReleased', x: -1, y: -1 })] + ]) + }) + + it("starts the page's CDP proxy outside the host's scope, so it outlives that host's request", async () => { + let startedInHostScope: boolean | undefined + CdpWsProxyMock.mockImplementationOnce(function (this: Record) { + this.start = vi.fn(async () => { + startedInHostScope = captureRemoteCliBrowserScope().bridged + return 'ws://127.0.0.1:9222' + }) + this.stop = vi.fn(async () => {}) + this.getPort = vi.fn(() => 9222) + CdpWsProxyMock.instances.push(this) + }) + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, JSON.stringify({ success: true, data: null }), '') + return createFakeAgentBrowserChild({ stdin: { on: vi.fn(), end: vi.fn() } }) + } + ) + + await runInRemoteCliBrowserScope(WORKSPACE, () => bridge.click('@e1', undefined, 'tab-1')) + + expect(startedInHostScope).toBe(false) + }) +}) diff --git a/src/main/browser/agent-browser-bridge-lifecycle.ts b/src/main/browser/agent-browser-bridge-lifecycle.ts index fdd1d387017a..1900c5767ef5 100644 --- a/src/main/browser/agent-browser-bridge-lifecycle.ts +++ b/src/main/browser/agent-browser-bridge-lifecycle.ts @@ -4,6 +4,7 @@ import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process' import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types' import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' +import { runOutsideRemoteCliBrowserScope } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess { async onTabClosed(webContentsId: number): Promise { @@ -126,7 +127,9 @@ export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawP }) } - const promise = createSession() + // Why: the proxy's sockets keep the async scope they were created in; a host's would refuse + // every later command on this page, the desktop's included, after that host's request ended. + const promise = runOutsideRemoteCliBrowserScope(createSession) this.pendingSessionCreation.set(sessionName, promise) try { await promise @@ -157,15 +160,23 @@ export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawP const session = this.sessions.get(sessionName) if (!session) { - this.rejectQueuedCommandsForClosedSession(sessionName) + if (!options.keepQueuedCommands) { + this.rejectQueuedCommandsForClosedSession(sessionName) + } return } this.sessions.delete(sessionName) this.pendingSessionCreation.delete(sessionName) + // Why: a retired session's page stays open, so the fresh one must keep its interception. + if (options.keepQueuedCommands && session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } // Why: queued commands would hang forever if we just delete the queue โ€” drain and reject them. - this.rejectQueuedCommandsForClosedSession(sessionName) + if (!options.keepQueuedCommands) { + this.rejectQueuedCommandsForClosedSession(sessionName) + } if (session.activeProcess) { // Why: rejecting the queue isn't enough for an in-flight command โ€” kill the process so callers don't wait out the exec timeout. diff --git a/src/main/browser/agent-browser-bridge-mouse-commands.ts b/src/main/browser/agent-browser-bridge-mouse-commands.ts index 1aaef42a16cb..d20b73e194a3 100644 --- a/src/main/browser/agent-browser-bridge-mouse-commands.ts +++ b/src/main/browser/agent-browser-bridge-mouse-commands.ts @@ -7,6 +7,8 @@ import { resolveMobileTouchClickPoint } from './agent-browser-bridge-mouse' import { acquireElectronDebugger } from './electron-debugger-lease' +import { sendGuestCdpCommand } from './guest-cdp-command' +import { assertRemoteCliBrowserAccess } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' import { AgentBrowserBridgePointerCommands } from './agent-browser-bridge-pointer-commands' export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgePointerCommands { @@ -39,6 +41,8 @@ export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridge let releaseFocusGuard = (): void => {} try { releaseFocusGuard = await this.focusPageForPress(wc, target.webContentsId) + // Why: the hit test below can activate the element, and access may be revoked meanwhile. + assertRemoteCliBrowserAccess(target.webContentsId) const point = cdpButton === 'left' ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. @@ -47,7 +51,7 @@ export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridge // Why: land the tap as one atomic op โ€” separate move/down/up CLI calls visibly hover and can miss small controls. // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. if (!point.handled) { - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + await sendGuestCdpCommand(wc, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, @@ -56,7 +60,7 @@ export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridge modifiers: cdpModifiers, clickCount: 1 }) - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + await sendGuestCdpCommand(wc, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, diff --git a/src/main/browser/agent-browser-bridge-pointer-commands.ts b/src/main/browser/agent-browser-bridge-pointer-commands.ts index d79e0a919a0c..1377fd843696 100644 --- a/src/main/browser/agent-browser-bridge-pointer-commands.ts +++ b/src/main/browser/agent-browser-bridge-pointer-commands.ts @@ -10,6 +10,7 @@ import { type CdpPointerState } from './cdp-pointer-input' import { acquireElectronDebugger } from './electron-debugger-lease' +import { sendGuestCdpCommand } from './guest-cdp-command' import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands' import { isEmbeddedGuest } from './guest-direct-key-input' import { enablePageFocusEmulation } from './agent-input-focus-guard' @@ -64,7 +65,7 @@ export abstract class AgentBrowserBridgePointerCommands extends AgentBrowserBrid if (press) { releaseFocusGuard = await this.focusPageForPress(wc, target.webContentsId) } - await wc.debugger.sendCommand('Input.dispatchMouseEvent', params) + await sendGuestCdpCommand(wc, 'Input.dispatchMouseEvent', params) return result } catch (error) { Object.assign(state, preDispatch) diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts index bf9826119691..910ffba8f073 100644 --- a/src/main/browser/agent-browser-bridge-queue.ts +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -6,7 +6,13 @@ import type { EnqueueTargetedCommandOptions, ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' -import { captureRemoteCliBrowserRevocation } from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' +import { runWithRemoteCliPageAdmission } from './remote-cli-page-admission' +import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' +import { releaseHeldPageInput } from './guest-direct-key-input' +import { + captureRemoteCliBrowserRevocation, + captureRemoteCliBrowserScope +} from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. @@ -61,6 +67,7 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown ): Promise { this.assertCommandAdmission() const assertNotRevoked = captureRemoteCliBrowserRevocation() + const origin = captureRemoteCliBrowserScope() assertNotRevoked() const { browserPageId: pageId } = this.resolveCommandTarget( worktreeId, @@ -78,15 +85,22 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown queue.push({ execute: () => { assertNotRevoked() - return this.executeQueuedCommand( - worktreeId, - pageId, - (session, target) => { - assertNotRevoked(target.webContentsId) - return execute(session, target) - }, - options - ) + const run = (): Promise => + this.executeQueuedCommand( + worktreeId, + pageId, + (session, target) => { + const gate = (): void => assertNotRevoked(target.webContentsId) + gate() + return origin.bridged + ? this.runHostCommand(session, target.webContentsId, gate, origin.revoked, () => + execute(session, target) + ) + : execute(session, target) + }, + options + ) + return origin.run(run) }, resolve: resolve as (value: unknown) => void, reject @@ -111,6 +125,33 @@ export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown return execute(sessionName, target) } + /** Holds the page for a host's command, so a revocation stops every later step of it. */ + private runHostCommand( + sessionName: string, + webContentsId: number, + gate: () => void, + revoked: AbortSignal | undefined, + execute: () => Promise + ): Promise { + return runWithRemoteCliPageAdmission(webContentsId, gate, execute, { + revoked, + // Why keep the queue: commands waiting on the page still run, on a fresh helper session. + retire: () => + this.destroySession(sessionName, { + closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS, + keepQueuedCommands: true + }), + releaseHeld: (release) => { + const wc = this.getWebContents(webContentsId) + return wc + ? releaseHeldPageInput(wc, release, () => + this.browserManager.guardUserFocusForAgentInput(webContentsId) + ) + : Promise.resolve() + } + }) + } + protected async processQueue(sessionName: string): Promise { if (this.processingQueues.has(sessionName)) { return diff --git a/src/main/browser/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser-bridge-tabs.ts index 0af381f6d964..c61ff8c6d5e0 100644 --- a/src/main/browser/agent-browser-bridge-tabs.ts +++ b/src/main/browser/agent-browser-bridge-tabs.ts @@ -78,6 +78,13 @@ export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { // โ”€โ”€ Tab management โ”€โ”€ + /** The workspace of every registered tab; the raw tab list carries none. */ + tabWorktreeIds(): (string | undefined)[] { + return [...this.getRegisteredTabs(undefined)].map(([tabId]) => + this.browserManager.getWorktreeIdForTab(tabId) + ) + } + tabList(worktreeId?: string): BrowserTabListResult { const tabs = this.getRegisteredTabs(worktreeId) // Why: use the per-worktree active tab so listing matches command routing, but read-only โ€” discovery must not mutate active-tab state. diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts index 2d4236afdbd8..ad5d1747a6c4 100644 --- a/src/main/browser/agent-browser-bridge-types.ts +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -43,6 +43,8 @@ export type ResolvedBrowserCommandTarget = { export type AgentBrowserCleanupOptions = { closeTimeoutMs?: number + /** Retire only the helper and proxy; commands waiting on the page still run, on a fresh session. */ + keepQueuedCommands?: boolean } export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' diff --git a/src/main/browser/agent-input-focus-guard.test.ts b/src/main/browser/agent-input-focus-guard.test.ts index a815613f70ca..1dece0273b82 100644 --- a/src/main/browser/agent-input-focus-guard.test.ts +++ b/src/main/browser/agent-input-focus-guard.test.ts @@ -101,6 +101,17 @@ describe('guardPaneFocusForAgentInput', () => { await vi.advanceTimersByTimeAsync(150) expect(settled).toBe('refused') }) + it('refuses the input at once and ends the guard while the user composes', async () => { + const { sent, renderer } = fakeRenderer(7, false) + + const guarded = guardPaneFocusForAgentInput(renderer, 'page-1') + const [listener] = ipcListeners.get(AGENT_INPUT_FOCUS_GUARD_READY_CHANNEL) ?? [] + listener({ sender: { id: 7 } }, { token: sent[0].data.token, composing: true }) + + await expect(guarded).rejects.toMatchObject({ code: 'browser_user_composing' }) + expect(sent.map(({ data }) => data.active)).toEqual([true, false]) + expect(ipcListeners.get(AGENT_INPUT_FOCUS_GUARD_READY_CHANNEL)).toEqual([]) + }) }) describe('isFocusTakingCdpInput', () => { diff --git a/src/main/browser/agent-input-focus-guard.ts b/src/main/browser/agent-input-focus-guard.ts index 6e697ad4387b..68f36ccf060d 100644 --- a/src/main/browser/agent-input-focus-guard.ts +++ b/src/main/browser/agent-input-focus-guard.ts @@ -24,16 +24,25 @@ export function focusGuardUnavailableError(): BrowserError { ) } +// Why: blurring the user's open IME composition commits its unfinished text (#28700). Refused +// rather than held: a held press could outlive its agent or land in the user's next composition. +function userComposingError(): BrowserError { + return new BrowserError( + 'browser_user_composing', + 'The user is typing with an input method in Orca, so the input was not sent to keep their unfinished text. Retry in a moment.' + ) +} + export async function guardPaneFocusForAgentInput( renderer: WebContents, browserPageId: string ): Promise<() => void> { const token = randomUUID() - const answered = await new Promise((resolve) => { - const finish = (ready: boolean): void => { + const answer = await new Promise<'ready' | 'unavailable' | 'composing'>((resolve) => { + const finish = (outcome: 'ready' | 'unavailable' | 'composing'): void => { clearTimeout(timer) ipcMain.removeListener(AGENT_INPUT_FOCUS_GUARD_READY_CHANNEL, onReady) - resolve(ready) + resolve(outcome) } const onReady = (event: Electron.IpcMainEvent, reply: unknown): void => { if ( @@ -43,10 +52,10 @@ export async function guardPaneFocusForAgentInput( 'token' in reply && reply.token === token ) { - finish(true) + finish('composing' in reply && reply.composing === true ? 'composing' : 'ready') } } - const timer = setTimeout(() => finish(false), PANE_READY_TIMEOUT_MS) + const timer = setTimeout(() => finish('unavailable'), PANE_READY_TIMEOUT_MS) ipcMain.on(AGENT_INPUT_FOCUS_GUARD_READY_CHANNEL, onReady) renderer.send(AGENT_INPUT_FOCUS_GUARD_CHANNEL, { browserPageId, token, active: true }) }) @@ -60,10 +69,10 @@ export async function guardPaneFocusForAgentInput( renderer.send(AGENT_INPUT_FOCUS_GUARD_CHANNEL, { browserPageId, token, active: false }) } } - if (!answered) { + if (answer !== 'ready') { // Why: input sent without a pane holding the user's element would take their focus for good. release() - throw focusGuardUnavailableError() + throw answer === 'composing' ? userComposingError() : focusGuardUnavailableError() } return release } diff --git a/src/main/browser/agent-input-injection.ts b/src/main/browser/agent-input-injection.ts index e5ec1cc6b4a9..dd6a3fc0d1ce 100644 --- a/src/main/browser/agent-input-injection.ts +++ b/src/main/browser/agent-input-injection.ts @@ -1,4 +1,5 @@ import type { WebContents } from 'electron' +import { assertRemoteCliPageAccess } from './remote-cli-page-admission' // Why: Orca's guest shortcut and grab handlers act on keys the page receives, and an unhandled key // can reach the app menu. Keys an agent injects must do neither (#20595), whichever path sends them. @@ -13,6 +14,7 @@ export async function withAgentInputInjection( webContents: WebContents, run: () => Promise ): Promise { + assertRemoteCliPageAccess(webContents.id) const depth = injectionDepth.get(webContents) ?? 0 injectionDepth.set(webContents, depth + 1) if (depth === 0) { diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index e13801282f5e..3948322730ed 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -240,6 +240,7 @@ describe('browserManager', () => { const guest = { isDestroyed: vi.fn(() => false), getType: vi.fn(() => 'webview'), + getURL: vi.fn(() => 'https://example.com/'), setBackgroundThrottling: guestSetBackgroundThrottlingMock, setWindowOpenHandler: guestSetWindowOpenHandlerMock, on: guestOnMock, @@ -260,6 +261,32 @@ describe('browserManager', () => { expect(preventDefault).toHaveBeenCalledTimes(1) }) + it.each([ + ['https://example.com/', true], + ['file:///docs/plan.html', false] + ])('from %s, a file: link navigation is blocked=%s', (currentUrl, blocked) => { + const guest = { + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + getURL: vi.fn(() => currentUrl), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + + const willNavigateHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'will-navigate' + )?.[1] as ((event: { preventDefault: () => void }, url: string) => void) | undefined + const preventDefault = vi.fn() + willNavigateHandler?.({ preventDefault }, 'file:///docs/index.html') + expect(preventDefault).toHaveBeenCalledTimes(blocked ? 1 : 0) + }) + it('unregisterAll clears tracked guests and context-menu listeners', () => { const guest = { id: 101, diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts index 5b30b4a34b1c..0ef9fac89672 100644 --- a/src/main/browser/browser-manager-guest-navigation-policy.ts +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -13,7 +13,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { return true } - // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. + // Why: initial file:// attach is allowed for user-opened previews, but block web โ†’ file:// navs and redirects so remote pages can't probe the FS. if (url.startsWith('file:')) { event.preventDefault() return false @@ -26,6 +26,15 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager return true } + const willNavigateHandler = (event: Electron.Event, url: string): void => { + // Why: a local page's own links (e.g. relative ) stay file:// โ†’ file://, as in Chrome; + // web pages still can't navigate into the filesystem. + if (url.startsWith('file:') && guest.getURL().startsWith('file:')) { + return + } + navigationGuard(event, url) + } + const willRedirectHandler = ( event: Electron.Event, url: string, @@ -127,7 +136,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager this.notifyBrowserGuestStateChanged(guest.id) } - guest.on('will-navigate', navigationGuard) + guest.on('will-navigate', willNavigateHandler) guest.on('will-redirect', willRedirectHandler) guest.on('did-start-navigation', didStartNavigationHandler) guest.on('did-navigate', didNavigateHandler) @@ -151,7 +160,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager // guest may already be destroyed } if (!guest.isDestroyed()) { - guest.off('will-navigate', navigationGuard) + guest.off('will-navigate', willNavigateHandler) guest.off('will-redirect', willRedirectHandler) guest.off('did-start-navigation', didStartNavigationHandler) guest.off('did-navigate', didNavigateHandler) diff --git a/src/main/browser/cdp-ws-proxy-host-admission.test.ts b/src/main/browser/cdp-ws-proxy-host-admission.test.ts new file mode 100644 index 000000000000..8a18a963eafb --- /dev/null +++ b/src/main/browser/cdp-ws-proxy-host-admission.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { CdpWsProxy } from './cdp-ws-proxy' +import { runWithRemoteCliPageAdmission } from './remote-cli-page-admission' +import { sendGuestCdpCommand } from './guest-cdp-command' +import { releaseHeldPageInput } from './guest-direct-key-input' +import { + connect, + createMockWebContents, + getSendCommandMethods, + sendAndReceive, + type MockWebContents +} from './cdp-ws-proxy-test-harness' + +vi.mock('electron', () => ({ + webContents: { fromId: vi.fn() } +})) + +const PAGE_ID = 7 +const REVOKED = 'The Orca desktop stopped sharing its browser before this command ran.' + +function keyUps(mock: MockWebContents): string[] { + return [ + ...mock.webContents.sendInputEvent.mock.calls.map((call) => call[0].type), + ...mock.webContents.debugger.sendCommand.mock.calls + .filter((call) => call[0] === 'Input.dispatchKeyEvent') + .map((call) => String(call[1]?.type)) + ].filter((type) => type === 'keyUp') +} + +function mouseEvents(mock: MockWebContents): string[] { + return mock.webContents.debugger.sendCommand.mock.calls + .filter((call) => call[0] === 'Input.dispatchMouseEvent') + .map((call) => String(call[1]?.type)) +} + +describe('CdpWsProxy under a host command', () => { + let mock: MockWebContents + let proxy: CdpWsProxy + let endpoint: string + let guardFocus: () => Promise<() => void> + + beforeEach(async () => { + mock = createMockWebContents() + guardFocus = async () => () => {} + const page = { ...mock.webContents, id: PAGE_ID } + proxy = new CdpWsProxy( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements every WebContents member the proxy calls. + page as never, + () => () => {}, + () => guardFocus() + ) + endpoint = await proxy.start() + }) + + afterEach(async () => { + await proxy.stop() + }) + + it('refuses every helper step after the host is revoked, without sending it to the page', async () => { + let revoked = false + let finish = (): void => {} + const held = runWithRemoteCliPageAdmission( + PAGE_ID, + () => { + if (revoked) { + throw new Error(REVOKED) + } + }, + () => new Promise((resolve) => (finish = resolve)) + ) + const client = await connect(endpoint) + const press = { type: 'mouseMoved', x: 1, y: 1 } + + const allowed = await sendAndReceive(client, { + id: 1, + method: 'Input.dispatchMouseEvent', + params: press + }) + revoked = true + const refused = await sendAndReceive(client, { + id: 2, + method: 'Input.dispatchMouseEvent', + params: press + }) + + expect(allowed.error).toBeUndefined() + expect(refused).toMatchObject({ id: 2, error: { message: REVOKED } }) + expect(mouseEvents(mock)).toEqual(['mouseMoved']) + finish() + await held + client.close() + }) + + function holdPage(): { revoke: () => void; release: () => Promise } { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock implements every WebContents member the release path calls. + const page = { ...mock.webContents, id: PAGE_ID } as never + let revoked = false + let finish = (): void => {} + const held = runWithRemoteCliPageAdmission( + PAGE_ID, + () => { + if (revoked) { + throw new Error(REVOKED) + } + }, + () => new Promise((resolve) => (finish = resolve)), + { releaseHeld: (release) => releaseHeldPageInput(page, release, () => guardFocus()) } + ) + return { + revoke: () => (revoked = true), + release: async () => { + finish() + await held + } + } + } + + const MOUSE = 'Input.dispatchMouseEvent' + const KEY = 'Input.dispatchKeyEvent' + const pressed = { type: 'mousePressed', x: 1, y: 1, button: 'left', clickCount: 1 } + const released = { type: 'mouseReleased', x: 1, y: 1, button: 'left', clickCount: 1 } + const shiftDown = { type: 'rawKeyDown', key: 'Shift', code: 'ShiftLeft', modifiers: 8 } + const shiftUp = { type: 'keyUp', key: 'Shift', code: 'ShiftLeft' } + + it.each(['window', 'webview'])( + 'refuses a revoked release, then lets go of the button and key itself (%s page)', + async (pageType) => { + mock.webContents.getType.mockReturnValue(pageType) + const page = holdPage() + const client = await connect(endpoint) + + await sendAndReceive(client, { id: 5, method: MOUSE, params: pressed }) + await sendAndReceive(client, { id: 6, method: KEY, params: shiftDown }) + page.revoke() + const mouseUp = await sendAndReceive(client, { id: 7, method: MOUSE, params: released }) + const keyUp = await sendAndReceive(client, { id: 8, method: KEY, params: shiftUp }) + + expect(mouseUp).toMatchObject({ error: { message: REVOKED } }) + expect(keyUp).toMatchObject({ error: { message: REVOKED } }) + expect(mouseEvents(mock)).toEqual(['mousePressed']) + expect(keyUps(mock)).toHaveLength(0) + await page.release() + // Off the page, so letting go completes no click the host had started. + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith( + MOUSE, + expect.objectContaining({ type: 'mouseReleased', x: -1, y: -1 }) + ) + expect(mouseEvents(mock)).toEqual(['mousePressed', 'mouseReleased']) + expect(keyUps(mock)).toHaveLength(1) + client.close() + } + ) + + it('lets go of nothing when the command released what it pressed before the revocation', async () => { + const page = holdPage() + const client = await connect(endpoint) + + await sendAndReceive(client, { id: 11, method: MOUSE, params: pressed }) + await sendAndReceive(client, { id: 12, method: MOUSE, params: released }) + page.revoke() + await page.release() + + expect(mouseEvents(mock)).toEqual(['mousePressed', 'mouseReleased']) + client.close() + }) + + it('refuses a release of something the revoked command never pressed', async () => { + const page = holdPage() + const client = await connect(endpoint) + page.revoke() + + const mouseUp = await sendAndReceive(client, { id: 9, method: MOUSE, params: released }) + const keyUp = await sendAndReceive(client, { id: 10, method: KEY, params: shiftUp }) + + expect(mouseUp).toMatchObject({ error: { message: REVOKED } }) + expect(keyUp).toMatchObject({ error: { message: REVOKED } }) + expect(mouseEvents(mock)).toEqual([]) + await page.release() + client.close() + }) + + it('drops a press it accepted before the revocation but had not delivered yet', async () => { + mock.webContents.getType.mockReturnValue('webview') + let grantFocus = (): void => {} + guardFocus = () => new Promise((resolve) => (grantFocus = () => resolve(() => {}))) + let revoked = false + let finish = (): void => {} + const held = runWithRemoteCliPageAdmission( + PAGE_ID, + () => { + if (revoked) { + throw new Error(REVOKED) + } + }, + () => new Promise((resolve) => (finish = resolve)) + ) + const client = await connect(endpoint) + + const reply = sendAndReceive(client, { + id: 3, + method: 'Input.dispatchMouseEvent', + params: { type: 'mousePressed', x: 1, y: 1, button: 'left', clickCount: 1 } + }) + await vi.waitFor(() => + expect(getSendCommandMethods(mock)).toContain('Emulation.setFocusEmulationEnabled') + ) + revoked = true + grantFocus() + + await expect(reply).resolves.toMatchObject({ id: 3, error: { message: REVOKED } }) + expect(mouseEvents(mock)).toEqual([]) + finish() + await held + client.close() + }) + + it('still refuses a deferred press after the revoked command let go of the page', async () => { + mock.webContents.getType.mockReturnValue('webview') + let grantFocus = (): void => {} + guardFocus = () => new Promise((resolve) => (grantFocus = () => resolve(() => {}))) + const page = holdPage() + const client = await connect(endpoint) + + const reply = sendAndReceive(client, { id: 13, method: MOUSE, params: pressed }) + await vi.waitFor(() => + expect(getSendCommandMethods(mock)).toContain('Emulation.setFocusEmulationEnabled') + ) + page.revoke() + await page.release() + grantFocus() + + await expect(reply).resolves.toMatchObject({ id: 13, error: { message: REVOKED } }) + expect(mouseEvents(mock)).toEqual([]) + client.close() + }) + + it('lets go of a button whose own release failed to land', async () => { + const page = holdPage() + const client = await connect(endpoint) + const send = mock.webContents.debugger.sendCommand.getMockImplementation() + mock.webContents.debugger.sendCommand.mockImplementationOnce(async () => ({})) + mock.webContents.debugger.sendCommand.mockImplementationOnce(async () => { + throw new Error('target closed') + }) + + await sendAndReceive(client, { id: 14, method: MOUSE, params: pressed }) + const failed = await sendAndReceive(client, { id: 15, method: MOUSE, params: released }) + if (send) { + mock.webContents.debugger.sendCommand.mockImplementation(send) + } + page.revoke() + await page.release() + + expect(failed.error).toBeDefined() + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith( + MOUSE, + expect.objectContaining({ type: 'mouseReleased', x: -1, y: -1 }) + ) + client.close() + }) + + it('forwards commands again once the host command ends', async () => { + await runWithRemoteCliPageAdmission( + PAGE_ID, + () => { + throw new Error(REVOKED) + }, + async () => {} + ) + const client = await connect(endpoint) + + const response = await sendAndReceive(client, { + id: 4, + method: 'Accessibility.getFullAXTree', + params: {} + }) + + expect(response.error).toBeUndefined() + expect(getSendCommandMethods(mock)).toContain('Accessibility.getFullAXTree') + client.close() + }) + + it('never refuses the desktop its own page commands while a revoked host command unwinds', async () => { + let finish = (): void => {} + const held = runWithRemoteCliPageAdmission( + PAGE_ID, + () => { + throw new Error(REVOKED) + }, + () => new Promise((resolve) => (finish = resolve)) + ) + + await expect( + sendGuestCdpCommand( + { ...mock.webContents, id: PAGE_ID }, + 'Emulation.setDeviceMetricsOverride', + { + width: 400, + height: 300, + deviceScaleFactor: 1, + mobile: false + } + ) + ).resolves.toEqual({}) + finish() + await held + }) +}) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index b074a61eae3c..127ad59f3f20 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -12,6 +12,10 @@ import type { CapturePaintHold } from './cdp-screenshot' import type { AgentInputFocusGuard } from './agent-input-focus-guard' import { CdpPageInputCommands } from './cdp-page-input-commands' import { isTrustedCdpLoopbackRequest } from './cdp-loopback-request-guard' +import { + assertRemoteCliPageAccess, + runUnderRemoteCliPageAdmission +} from './remote-cli-page-admission' export class CdpWsProxy { private httpServer: Server | null = null @@ -96,7 +100,9 @@ export class CdpWsProxy { this.closeClient() this.client = ws const onMessage = (data: WebSocket.RawData): void => { - this.handleClientMessage(ws, data.toString()) + runUnderRemoteCliPageAdmission(this.webContents.id, () => + this.handleClientMessage(ws, data.toString()) + ) } const onClose = (): void => { detach() @@ -178,6 +184,17 @@ export class CdpWsProxy { } const clientId = msg.id this.responder.recordRequestSessionId(client, clientId, msg) + // Why here too: a revoked host's helper is refused even its read-only steps, so it stops at once. + try { + assertRemoteCliPageAccess(this.webContents.id) + } catch (error) { + this.responder.sendError( + clientId, + error instanceof Error ? error.message : String(error), + client + ) + return + } if (this.discovery.handleCommand(client, clientId, msg)) { return diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts index 391c96216fb5..12de9ee580f1 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-connection.ts @@ -28,6 +28,7 @@ export class DesktopBrowserBridgeConnection { private attachmentId: string | null = null private closed = false private readonly revoked = new AbortController() + private readonly cancellations = new Map() constructor( private readonly options: { @@ -102,15 +103,29 @@ export class DesktopBrowserBridgeConnection { this.lose(new Error('desktop_browser_bridge_request_before_ready')) return } + if (parsed.data.type === 'cancel') { + this.cancellations.get(parsed.data.requestId)?.abort() + return + } void this.answer(this.attachmentId, parsed.data) } private async answer(attachmentId: string, request: DesktopBrowserBridgeRequest): Promise { // Why: once orcad has stopped waiting, a command still queued here must not run late. - const signal = request.timeoutMs - ? AbortSignal.any([this.revoked.signal, AbortSignal.timeout(request.timeoutMs)]) - : this.revoked.signal - const outcome = await this.options.execute(request, signal) + const cancelled = new AbortController() + this.cancellations.set(request.requestId, cancelled) + const signals = [this.revoked.signal, cancelled.signal] + if (request.timeoutMs) { + signals.push(AbortSignal.timeout(request.timeoutMs)) + } + let outcome: DesktopBrowserBridgeOutcome + try { + outcome = await this.options.execute(request, AbortSignal.any(signals)) + } finally { + if (this.cancellations.get(request.requestId) === cancelled) { + this.cancellations.delete(request.requestId) + } + } const sendRequest = this.subscription?.sendRequest if (this.closed || this.attachmentId !== attachmentId || !sendRequest) { return diff --git a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts index 2af864680d8d..cac10e444169 100644 --- a/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts +++ b/src/main/browser/desktop-browser-bridge/desktop-browser-bridge-manager.test.ts @@ -315,4 +315,44 @@ describe('DesktopBrowserBridgeManager', () => { expect(seen!.aborted).toBe(false) await vi.waitFor(() => expect(seen!.aborted).toBe(true)) }) + + it('stops a queued or running command when orcad cancels it, and stays attached', async () => { + const h = harness() + let seen: AbortSignal | null = null + let finish = (): void => {} + h.execute.mockImplementationOnce(async (_candidate, _request, signal) => { + seen = signal + await new Promise((resolve) => { + finish = resolve + }) + return { ok: true, result: {} } + }) + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + socket.callbacks.onResponse( + event({ type: 'request', requestId: 'r', method: 'browser.click', params: {} }) + ) + await vi.waitFor(() => expect(seen).not.toBeNull()) + expect(seen!.aborted).toBe(false) + + socket.callbacks.onResponse(event({ type: 'cancel', requestId: 'r' })) + + expect(seen!.aborted).toBe(true) + expect(h.manager.isAttached('env-a')).toBe(true) + finish() + }) + + it('ignores a cancel for a request it is not running', async () => { + const h = harness() + h.manager.reconcile() + await vi.waitFor(() => expect(h.subscriptions).toHaveLength(1)) + const socket = h.subscriptions[0]! + socket.callbacks.onResponse(event({ type: 'ready', attachmentId: 'att-1' })) + + socket.callbacks.onResponse(event({ type: 'cancel', requestId: 'unknown' })) + + expect(h.manager.isAttached('env-a')).toBe(true) + }) }) diff --git a/src/main/browser/guest-cdp-command.ts b/src/main/browser/guest-cdp-command.ts index ed7735df2d87..d71bca59b8ea 100644 --- a/src/main/browser/guest-cdp-command.ts +++ b/src/main/browser/guest-cdp-command.ts @@ -1,5 +1,6 @@ import type { WebContents } from 'electron' import { BrowserError } from './browser-error' +import { assertRemoteCliPageAccess, trackRemoteCliInput } from './remote-cli-page-admission' // Why: Chromium resizes the page's view for these without checking the view still exists // (WebContentsImpl::SetDeviceEmulationSize). A crashed renderer takes its view with it until the @@ -10,6 +11,7 @@ const VIEW_RESIZING_CDP_METHODS: ReadonlySet = new Set([ ]) type GuestCdpTarget = Pick & { + id?: number debugger: Pick } @@ -24,6 +26,12 @@ export function sendGuestCdpCommand( params?: Record, sessionId?: string ): Promise { + try { + // Why: only a host's own work carries its scope; desktop-owned sends are never refused here. + assertRemoteCliPageAccess(guest.id) + } catch (error) { + return Promise.reject(error) + } // Why same task as the send: isCrashed() flips in the same Chromium task that drops the view, // so checking right before sendCommand leaves no gap for the renderer to die in between. if (VIEW_RESIZING_CDP_METHODS.has(method) && (guest.isDestroyed() || guest.isCrashed())) { @@ -34,9 +42,11 @@ export function sendGuestCdpCommand( ) ) } - return Promise.resolve( - sessionId === undefined - ? guest.debugger.sendCommand(method, params) - : guest.debugger.sendCommand(method, params, sessionId) + return trackRemoteCliInput(guest.id, method, params, () => + Promise.resolve( + sessionId === undefined + ? guest.debugger.sendCommand(method, params) + : guest.debugger.sendCommand(method, params, sessionId) + ) ) } diff --git a/src/main/browser/guest-direct-key-input.test.ts b/src/main/browser/guest-direct-key-input.test.ts index 1c14b08905cf..6fdf71ebd871 100644 --- a/src/main/browser/guest-direct-key-input.test.ts +++ b/src/main/browser/guest-direct-key-input.test.ts @@ -2,11 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import type { WebContents } from 'electron' import { dispatchPageKeyEvent, - planCdpKeyEvent, - planInsertText, routePageTextInsertion, type GuestCdpSender } from './guest-direct-key-input' +import { planCdpKeyEvent, planInsertText } from './guest-key-event-plan' function fakePage(type: string) { const page = { diff --git a/src/main/browser/guest-direct-key-input.ts b/src/main/browser/guest-direct-key-input.ts index dd2d559a6755..a33b34dd299c 100644 --- a/src/main/browser/guest-direct-key-input.ts +++ b/src/main/browser/guest-direct-key-input.ts @@ -1,4 +1,4 @@ -import type { InputEvent, KeyboardInputEvent, WebContents } from 'electron' +import type { WebContents } from 'electron' import { enablePageFocusEmulation, type AgentInputFocusGuard } from './agent-input-focus-guard' import { withAgentInputInjection } from './agent-input-injection' import { @@ -7,179 +7,30 @@ import { type GuestSessionCdpSender } from './guest-focused-frame-route' import { sendFrameSessionInput } from './guest-frame-input-sessions' +import { planCdpKeyEvent, planInsertText, type GuestEditCommand } from './guest-key-event-plan' +import { sendGuestCdpCommand } from './guest-cdp-command' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { normalizeCdpPointerButton } from './agent-browser-bridge-mouse' +import { cdpPointerStateFor, releaseCdpPointerButton } from './cdp-pointer-input' +import { + assertRemoteCliPageAccess, + trackRemoteCliInput, + type HeldInputRelease +} from './remote-cli-page-admission' // Why: Chromium routes main-session CDP key and text input on a guest by the // window's focus, so it lands in whatever the user is typing into (#20595). Events sent to // the guest's own widget reach its page without moving focus. // See docs/reference/agent-browser-focus-isolation.md. -type ElectronModifier = NonNullable[number] - -// Why: Electron honours this NativeWebKeyboardEvent flag although its typings omit it; an -// unhandled agent key must not be redispatched to Orca's window or application menu. -type GuestKeyboardEvent = KeyboardInputEvent & { skipIfUnhandled: true } - -type GuestEditCommand = 'selectAll' | 'copy' | 'cut' | 'paste' | 'undo' | 'redo' | 'delete' - -type GuestKeyPlan = { - events: GuestKeyboardEvent[] - // Why: macOS editing shortcuts arrive as CDP `commands`; widget key events skip the - // browser-side key bindings that would run them. - commands: GuestEditCommand[] -} - export type GuestCdpSender = GuestSessionCdpSender -const CDP_MODIFIER_NAMES: [number, ElectronModifier][] = [ - [1, 'alt'], - [2, 'control'], - [4, 'meta'], - [8, 'shift'] -] - -// CDP `key` -> Electron accelerator key code, for keys whose names differ or are not single characters. -const ELECTRON_KEY_CODES: Record = { - Enter: 'Enter', - Tab: 'Tab', - Backspace: 'Backspace', - Delete: 'Delete', - Escape: 'Escape', - ' ': 'Space', - ArrowUp: 'Up', - ArrowDown: 'Down', - ArrowLeft: 'Left', - ArrowRight: 'Right', - Home: 'Home', - End: 'End', - PageUp: 'PageUp', - PageDown: 'PageDown', - Insert: 'Insert', - CapsLock: 'CapsLock', - NumLock: 'NumLock', - ScrollLock: 'ScrollLock', - PrintScreen: 'PrintScreen', - Shift: 'Shift', - Control: 'Control', - Alt: 'Alt', - Meta: 'Meta' -} - -const GUEST_EDIT_COMMANDS: ReadonlySet = new Set([ - 'selectAll', - 'copy', - 'cut', - 'paste', - 'undo', - 'redo', - 'delete' -]) - -function isGuestEditCommand(command: unknown): command is GuestEditCommand { - return typeof command === 'string' && GUEST_EDIT_COMMANDS.has(command) -} - // Why: getType() is 'webview' only for guests hosted in an Orca window; offscreen and // headless pages have no host frame tree, so CDP input already reaches them. export function isEmbeddedGuest(webContents: WebContents): boolean { return webContents.getType() === 'webview' } -function electronKeyCode(key: unknown): string | null { - if (typeof key !== 'string') { - return null - } - if (Object.hasOwn(ELECTRON_KEY_CODES, key)) { - return ELECTRON_KEY_CODES[key] - } - if (/^F([1-9]|1\d|2[0-4])$/.test(key)) { - return key - } - // Why: Electron derives key/code from a US layout, which covers printable ASCII only. - return key.length === 1 && key >= '!' && key <= '~' ? key : null -} - -function electronModifiers(mask: unknown): ElectronModifier[] { - const bits = typeof mask === 'number' ? mask : 0 - return CDP_MODIFIER_NAMES.filter(([bit]) => (bits & bit) !== 0).map(([, name]) => name) -} - -// Why: Blink drops control characters from char events ("a\nb" types "ab"), so text that -// carries them cannot be typed this way and must take the fallback. -function typeableText(text: unknown): text is string { - return ( - typeof text === 'string' && - text.length > 0 && - Array.from(text).every((char) => { - const code = char.charCodeAt(0) - return code >= 0x20 && code !== 0x7f - }) - ) -} - -function charEvents(text: string, modifiers: ElectronModifier[]): GuestKeyboardEvent[] { - return Array.from(text, (char) => ({ - type: 'char', - keyCode: char, - modifiers, - skipIfUnhandled: true - })) -} - -/** Translates one CDP Input.dispatchKeyEvent into guest widget events, or null when it cannot. */ -export function planCdpKeyEvent(params: Record): GuestKeyPlan | null { - const commandsParam = params.commands ?? [] - if (!Array.isArray(commandsParam) || !commandsParam.every(isGuestEditCommand)) { - return null - } - const commands = commandsParam - const modifiers = electronModifiers(params.modifiers) - const { type, text } = params - if (type === 'char') { - return typeableText(text) ? { events: charEvents(text, modifiers), commands } : null - } - if (type !== 'keyDown' && type !== 'rawKeyDown' && type !== 'keyUp') { - return null - } - const keyCode = electronKeyCode(params.key) - // Why: a named key's text is a control character (Tab "\t", Backspace "\b") that the key - // event already acts on; only Enter's "\r" char is needed, to submit forms and insert newlines. - const isNamedKey = - typeof params.key === 'string' && - params.key !== ' ' && - Object.hasOwn(ELECTRON_KEY_CODES, params.key) - if (type === 'keyDown' && !isNamedKey && typeof text === 'string' && text !== '') { - if (!typeableText(text)) { - return null - } - // Why: a non-US-layout character has no Electron key code; the char event alone types it. - const down: GuestKeyboardEvent[] = - keyCode === null ? [] : [{ type: 'keyDown', keyCode, modifiers, skipIfUnhandled: true }] - return { events: [...down, ...charEvents(text, modifiers)], commands } - } - if (keyCode === null) { - // Why: a character outside the US layout went down as a char event alone; its keyUp has no - // widget form, and the fallback would focus the guest just to release a key never pressed. - const isCharacterKey = typeof params.key === 'string' && Array.from(params.key).length === 1 - return type === 'keyUp' && isCharacterKey ? { events: [], commands } : null - } - const event: GuestKeyboardEvent = { - type: type === 'keyUp' ? 'keyUp' : 'keyDown', - keyCode, - modifiers, - skipIfUnhandled: true - } - const enterChar: GuestKeyboardEvent[] = - type === 'keyDown' && params.key === 'Enter' && (text === '\r' || text === '\n') - ? [{ type: 'char', keyCode: '\r', modifiers, skipIfUnhandled: true }] - : [] - return { events: [event, ...enterChar], commands } -} - -/** Translates CDP Input.insertText into guest widget char events, or null when it cannot. */ -export function planInsertText(text: unknown): GuestKeyboardEvent[] | null { - return typeableText(text) ? charEvents(text, []) : null -} - // Why: sendInputEvent returns before the page handles the event, but CDP input replies only after. // A task-queue round trip in the page lets callers read the result of what they just typed. async function waitForPageToHandleInput(send: GuestCdpSender): Promise { @@ -195,6 +46,8 @@ async function waitForPageToHandleInput(send: GuestCdpSender): Promise { } function runEditCommand(webContents: WebContents, command: GuestEditCommand): void { + // Why: it runs after awaiting the page, so a host's access may have been revoked meanwhile. + assertRemoteCliPageAccess(webContents.id) switch (command) { case 'selectAll': webContents.selectAll() @@ -285,17 +138,20 @@ export async function dispatchPageKeyEvent( ? await guardFocus() : () => {} try { - await withAgentInputInjection(webContents, async () => { - for (const event of plan.events) { - webContents.sendInputEvent(event) - } - if (plan.commands.length > 0 || plan.events.length > 0) { - await waitForPageToHandleInput(send) - } - for (const command of plan.commands) { - runEditCommand(webContents, command) - } - }) + await withAgentInputInjection(webContents, () => + // Why: widget events skip the CDP send, so this key is recorded on the page's hold here. + trackRemoteCliInput(webContents.id, 'Input.dispatchKeyEvent', params, async () => { + for (const event of plan.events) { + webContents.sendInputEvent(event) + } + if (plan.commands.length > 0 || plan.events.length > 0) { + await waitForPageToHandleInput(send) + } + for (const command of plan.commands) { + runEditCommand(webContents, command) + } + }) + ) } finally { release() } @@ -309,6 +165,31 @@ export async function dispatchPageKeyEvent( } } +/** Lets go of what a revoked host's command left pressed; a key goes where the focus routes it. */ +export async function releaseHeldPageInput( + webContents: WebContents, + release: HeldInputRelease, + guardFocus: AgentInputFocusGuard +): Promise { + const send: GuestCdpSender = (method, params, sessionId) => + sendGuestCdpCommand(webContents, method, params, sessionId) + // Why: the command's own debugger lease may already be gone, detaching the debugger. + const lease = acquireElectronDebugger(webContents) + try { + if (release.method === 'Input.dispatchKeyEvent') { + return await dispatchPageKeyEvent(webContents, release.params, send, guardFocus) + } + const result = await send(release.method, release.params) + if (release.method === 'Input.dispatchMouseEvent') { + const button = normalizeCdpPointerButton(String(release.params.button)) + releaseCdpPointerButton(cdpPointerStateFor(webContents), button) + } + return result + } finally { + lease.release() + } +} + export type TextInsertionRoute = { deliver: (params: Record) => Promise // Why: the fallback holds borrowed focus from routing until delivery; an insert abandoned in diff --git a/src/main/browser/guest-key-event-plan.ts b/src/main/browser/guest-key-event-plan.ts new file mode 100644 index 000000000000..32fe6f2036a3 --- /dev/null +++ b/src/main/browser/guest-key-event-plan.ts @@ -0,0 +1,162 @@ +import type { InputEvent, KeyboardInputEvent } from 'electron' + +/** Translates CDP key and text input into Electron guest widget events (#20595). */ + +type ElectronModifier = NonNullable[number] + +// Why: Electron honours this NativeWebKeyboardEvent flag although its typings omit it; an +// unhandled agent key must not be redispatched to Orca's window or application menu. +export type GuestKeyboardEvent = KeyboardInputEvent & { skipIfUnhandled: true } + +export type GuestEditCommand = 'selectAll' | 'copy' | 'cut' | 'paste' | 'undo' | 'redo' | 'delete' + +type GuestKeyPlan = { + events: GuestKeyboardEvent[] + // Why: macOS editing shortcuts arrive as CDP `commands`; widget key events skip the + // browser-side key bindings that would run them. + commands: GuestEditCommand[] +} + +const CDP_MODIFIER_NAMES: [number, ElectronModifier][] = [ + [1, 'alt'], + [2, 'control'], + [4, 'meta'], + [8, 'shift'] +] + +// CDP `key` -> Electron accelerator key code, for keys whose names differ or are not single characters. +const ELECTRON_KEY_CODES: Record = { + Enter: 'Enter', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + Escape: 'Escape', + ' ': 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + Insert: 'Insert', + CapsLock: 'CapsLock', + NumLock: 'NumLock', + ScrollLock: 'ScrollLock', + PrintScreen: 'PrintScreen', + Shift: 'Shift', + Control: 'Control', + Alt: 'Alt', + Meta: 'Meta' +} + +const GUEST_EDIT_COMMANDS: ReadonlySet = new Set([ + 'selectAll', + 'copy', + 'cut', + 'paste', + 'undo', + 'redo', + 'delete' +]) + +function isGuestEditCommand(command: unknown): command is GuestEditCommand { + return typeof command === 'string' && GUEST_EDIT_COMMANDS.has(command) +} + +function electronKeyCode(key: unknown): string | null { + if (typeof key !== 'string') { + return null + } + if (Object.hasOwn(ELECTRON_KEY_CODES, key)) { + return ELECTRON_KEY_CODES[key] + } + if (/^F([1-9]|1\d|2[0-4])$/.test(key)) { + return key + } + // Why: Electron derives key/code from a US layout, which covers printable ASCII only. + return key.length === 1 && key >= '!' && key <= '~' ? key : null +} + +function electronModifiers(mask: unknown): ElectronModifier[] { + const bits = typeof mask === 'number' ? mask : 0 + return CDP_MODIFIER_NAMES.filter(([bit]) => (bits & bit) !== 0).map(([, name]) => name) +} + +// Why: Blink drops control characters from char events ("a\nb" types "ab"), so text that +// carries them cannot be typed this way and must take the fallback. +function typeableText(text: unknown): text is string { + return ( + typeof text === 'string' && + text.length > 0 && + Array.from(text).every((char) => { + const code = char.charCodeAt(0) + return code >= 0x20 && code !== 0x7f + }) + ) +} + +function charEvents(text: string, modifiers: ElectronModifier[]): GuestKeyboardEvent[] { + return Array.from(text, (char) => ({ + type: 'char', + keyCode: char, + modifiers, + skipIfUnhandled: true + })) +} + +/** Translates one CDP Input.dispatchKeyEvent into guest widget events, or null when it cannot. */ +export function planCdpKeyEvent(params: Record): GuestKeyPlan | null { + const commandsParam = params.commands ?? [] + if (!Array.isArray(commandsParam) || !commandsParam.every(isGuestEditCommand)) { + return null + } + const commands = commandsParam + const modifiers = electronModifiers(params.modifiers) + const { type, text } = params + if (type === 'char') { + return typeableText(text) ? { events: charEvents(text, modifiers), commands } : null + } + if (type !== 'keyDown' && type !== 'rawKeyDown' && type !== 'keyUp') { + return null + } + const keyCode = electronKeyCode(params.key) + // Why: a named key's text is a control character (Tab "\t", Backspace "\b") that the key + // event already acts on; only Enter's "\r" char is needed, to submit forms and insert newlines. + const isNamedKey = + typeof params.key === 'string' && + params.key !== ' ' && + Object.hasOwn(ELECTRON_KEY_CODES, params.key) + if (type === 'keyDown' && !isNamedKey && typeof text === 'string' && text !== '') { + if (!typeableText(text)) { + return null + } + // Why: a non-US-layout character has no Electron key code; the char event alone types it. + const down: GuestKeyboardEvent[] = + keyCode === null ? [] : [{ type: 'keyDown', keyCode, modifiers, skipIfUnhandled: true }] + return { events: [...down, ...charEvents(text, modifiers)], commands } + } + if (keyCode === null) { + // Why: a character outside the US layout went down as a char event alone; its keyUp has no + // widget form, and the fallback would focus the guest just to release a key never pressed. + const isCharacterKey = typeof params.key === 'string' && Array.from(params.key).length === 1 + return type === 'keyUp' && isCharacterKey ? { events: [], commands } : null + } + const event: GuestKeyboardEvent = { + type: type === 'keyUp' ? 'keyUp' : 'keyDown', + keyCode, + modifiers, + skipIfUnhandled: true + } + const enterChar: GuestKeyboardEvent[] = + type === 'keyDown' && params.key === 'Enter' && (text === '\r' || text === '\n') + ? [{ type: 'char', keyCode: '\r', modifiers, skipIfUnhandled: true }] + : [] + return { events: [event, ...enterChar], commands } +} + +/** Translates CDP Input.insertText into guest widget char events, or null when it cannot. */ +export function planInsertText(text: unknown): GuestKeyboardEvent[] | null { + return typeableText(text) ? charEvents(text, []) : null +} diff --git a/src/main/browser/paired-runtime-browser-client-host.ts b/src/main/browser/paired-runtime-browser-client-host.ts index 6e248afe0f5b..7623016713c5 100644 --- a/src/main/browser/paired-runtime-browser-client-host.ts +++ b/src/main/browser/paired-runtime-browser-client-host.ts @@ -5,7 +5,7 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' -import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-subscription-contract' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { BrowserClientFileChannelAvailability } from './browser-client-file-channel-transport' import { BrowserClientHostCommandDispatcher } from './browser-client-host-command-dispatcher' diff --git a/src/main/browser/paired-runtime-browser-host-lease-options.ts b/src/main/browser/paired-runtime-browser-host-lease-options.ts index 72531968e685..0e4f6971b80d 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-options.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-options.ts @@ -5,7 +5,7 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' -import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-subscription-contract' export type PairedRuntimeBrowserHostLeaseOptions = { connection: PairedRuntimeConnection diff --git a/src/main/browser/paired-runtime-browser-network-route.test.ts b/src/main/browser/paired-runtime-browser-network-route.test.ts index fe22a53c5f9c..f22e52d05219 100644 --- a/src/main/browser/paired-runtime-browser-network-route.test.ts +++ b/src/main/browser/paired-runtime-browser-network-route.test.ts @@ -11,9 +11,9 @@ import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-e import type { PairingOffer } from '../../shared/pairing' import type { RemoteRuntimeSubscription, - RemoteRuntimeSubscriptionCallbacks, - RemoteRuntimeSubscriptionOptions + RemoteRuntimeSubscriptionCallbacks } from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-subscription-contract' const { subscribeRemoteRuntimeRequestMock } = vi.hoisted(() => ({ subscribeRemoteRuntimeRequestMock: vi.fn() diff --git a/src/main/browser/paired-runtime-browser-network-route.ts b/src/main/browser/paired-runtime-browser-network-route.ts index 9712f8711b78..8c35f73a189b 100644 --- a/src/main/browser/paired-runtime-browser-network-route.ts +++ b/src/main/browser/paired-runtime-browser-network-route.ts @@ -2,7 +2,7 @@ import type { BrowserHostLeaseAuthority } from '../../shared/browser-client-host import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host' import type { BrowserNetworkTunnelOpen } from '../../shared/browser-network-tunnel-protocol' import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' -import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-subscription-contract' import { resolveBrowserHostReconnectDelay } from './browser-host-lease-reconnect-delay' import { retryBrowserNetworkRouteReconnect } from './browser-network-route-reconnect-retry' import { diff --git a/src/main/browser/paired-runtime-browser-network-transport.ts b/src/main/browser/paired-runtime-browser-network-transport.ts index 26ee1339d001..76e167e54c07 100644 --- a/src/main/browser/paired-runtime-browser-network-transport.ts +++ b/src/main/browser/paired-runtime-browser-network-transport.ts @@ -6,9 +6,9 @@ import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-e import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' import { subscribeRemoteRuntimeRequest, - type RemoteRuntimeSubscription, - type RemoteRuntimeSubscriptionOptions + type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' +import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-subscription-contract' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/main/browser/remote-cli-page-admission.ts b/src/main/browser/remote-cli-page-admission.ts new file mode 100644 index 000000000000..e19476f28f97 --- /dev/null +++ b/src/main/browser/remote-cli-page-admission.ts @@ -0,0 +1,174 @@ +/** + * One SSH host command's hold on a page. While it runs, every page sink (guest CDP sends, native + * input, edit commands, the CDP proxy's intake) refuses work attributed to it once the host is + * revoked. Work is attributed by async context: the command's own through its bridged scope, its + * out-of-process helper's through the proxy message it arrived on. The hold also owns the + * command's input cancel list: what it pressed and has not released, let go of on revocation. + */ +import { AsyncLocalStorage } from 'node:async_hooks' +import { + assertRemoteCliBrowserAccess, + runOutsideRemoteCliBrowserScope +} from '../runtime/desktop-browser-bridge/remote-cli-browser-scope' + +/** The CDP input event that lets go of something a command pressed. */ +export type HeldInputRelease = { method: string; params: Record } + +export type PageHoldHooks = { + /** Fires when the host is revoked; `retire` then runs at once, while the page is still held. */ + revoked?: AbortSignal + /** Stops the command's helper, whose daemon outlives its killed client and keeps retrying. */ + retire?: () => Promise + releaseHeld?: (release: HeldInputRelease) => Promise +} + +type Admission = { + admit: () => void + /** Keyed by button, key or touch. */ + held: Map +} + +const admissions = new Map() +/** The hold a proxy message arrived under; its deferred steps keep it after the hold ends. */ +const messageAdmission = new AsyncLocalStorage<() => void>() + +/** + * Runs a host's command holding the page. On revocation every later step is refused, releases + * included, so once the command ends the desktop lets go of what it left held, from outside the + * host's scope. A failure after revocation reports the revocation. + */ +export async function runWithRemoteCliPageAdmission( + webContentsId: number, + admit: () => void, + run: () => Promise, + hooks: PageHoldHooks = {} +): Promise { + const admission: Admission = { admit, held: new Map() } + let retiring: Promise | undefined + const retire = (): Promise | undefined => + hooks.retire && (retiring ??= runOutsideRemoteCliBrowserScope(hooks.retire)) + const onRevoked = (): void => void retire() + hooks.revoked?.addEventListener('abort', onRevoked, { once: true }) + admissions.set(webContentsId, admission) + try { + try { + return await run() + } finally { + hooks.revoked?.removeEventListener('abort', onRevoked) + if (isRevoked(admit)) { + await retire() + } + if (admissions.get(webContentsId) === admission) { + admissions.delete(webContentsId) + } + const { releaseHeld } = hooks + if (releaseHeld && admission.held.size > 0 && isRevoked(admit)) { + await runOutsideRemoteCliBrowserScope(() => releaseAll(admission.held, releaseHeld)) + } + } + } catch (error) { + admit() + throw error + } +} + +/** Runs a proxy message's handling, deferred steps included, under the page's current hold. */ +export function runUnderRemoteCliPageAdmission(webContentsId: number, handle: () => T): T { + const admission = admissions.get(webContentsId) + return admission ? messageAdmission.run(admission.admit, handle) : handle() +} + +/** The page sinks' gate: throws for work of a revoked host's command; a no-op for the desktop's. */ +export function assertRemoteCliPageAccess(webContentsId?: number): void { + assertRemoteCliBrowserAccess(webContentsId) + messageAdmission.getStore()?.() +} + +/** + * Delivers one input event, recording it on the page's hold: a press counts as held once it may + * have landed, a release only once it surely did. + */ +export function trackRemoteCliInput( + webContentsId: number | undefined, + method: string, + params: Record | undefined, + deliver: () => Promise +): Promise { + const admission = webContentsId === undefined ? undefined : admissions.get(webContentsId) + if (!admission || !params) { + return deliver() + } + if (!['mouseReleased', 'keyUp', 'touchEnd', 'touchCancel'].includes(String(params.type))) { + trackInput(admission, method, params) + return deliver() + } + return deliver().then((result) => { + trackInput(admission, method, params) + return result + }) +} + +function isRevoked(admit: () => void): boolean { + try { + admit() + return false + } catch { + return true + } +} + +async function releaseAll( + held: Map, + releaseHeld: (release: HeldInputRelease) => Promise +): Promise { + for (const release of held.values()) { + try { + await releaseHeld(release) + } catch { + // Best effort: the page may be gone, and nothing else is waiting on this release. + } + } +} + +function trackInput(admission: Admission, method: string, params: Record): void { + const type = params.type + if (typeof type !== 'string') { + return + } + if ( + method === 'Input.dispatchMouseEvent' && + (type === 'mousePressed' || type === 'mouseReleased') + ) { + const id = `mouse:${String(params.button ?? 'left')}` + if (type === 'mouseReleased') { + admission.held.delete(id) + return + } + // Why off the page: letting go there completes no click or drop the host had started. + admission.held.set(id, { + method, + params: { type: 'mouseReleased', x: -1, y: -1, button: params.button ?? 'left', buttons: 0 } + }) + } else if ( + method === 'Input.dispatchKeyEvent' && + ['keyDown', 'rawKeyDown', 'keyUp'].includes(type) + ) { + const id = `key:${String(params.code ?? params.key ?? '')}` + if (type === 'keyUp') { + admission.held.delete(id) + return + } + const { key, code, windowsVirtualKeyCode, nativeVirtualKeyCode } = params + admission.held.set(id, { + method, + params: { type: 'keyUp', key, code, windowsVirtualKeyCode, nativeVirtualKeyCode } + }) + } else if (method === 'Input.dispatchTouchEvent' && type.startsWith('touch')) { + if (type === 'touchStart') { + // Why cancel: a cancelled touch completes no tap the host had started. + admission.held.set('touch', { method, params: { type: 'touchCancel', touchPoints: [] } }) + } else if (type === 'touchEnd' || type === 'touchCancel') { + admission.held.delete('touch') + } + } +} diff --git a/src/main/claude-usage/scanner-incremental-reader.test.ts b/src/main/claude-usage/scanner-incremental-reader.test.ts index 52e4ed25de95..cf56d92354bd 100644 --- a/src/main/claude-usage/scanner-incremental-reader.test.ts +++ b/src/main/claude-usage/scanner-incremental-reader.test.ts @@ -82,7 +82,7 @@ vi.mock('node:fs/promises', async () => { streaming = true stream.once('end', () => (streaming = false)) stream.once('close', () => (streaming = false)) - stream.on('data', (chunk: Buffer) => { + stream.on('data', (chunk) => { read.bytes += chunk.length }) return stream diff --git a/src/main/claude/claude-background-task-roster-merge.ts b/src/main/claude/claude-background-task-roster-merge.ts index 2b825a63835a..729ea081feab 100644 --- a/src/main/claude/claude-background-task-roster-merge.ts +++ b/src/main/claude/claude-background-task-roster-merge.ts @@ -3,22 +3,15 @@ import type { TrackedClaudeBackgroundTask } from './claude-settled-background-ta /** Keep live foreground work beside the provider's authoritative background roster. */ export function combineClaudeBackgroundTaskRoster( prior: ReadonlyMap, - roster: ReadonlyMap, - limit: number + roster: ReadonlyMap ): Map { const combined = new Map() - const retainable = [...prior].filter( - ([id, task]) => !task.backgrounded && task.liveInTurn && !roster.has(id) - ) - let evict = Math.max(0, roster.size + retainable.length - limit) - // Drop oldest foreground rows first; preserve the provider's roster order. - for (const [id, task] of retainable) { - if (evict > 0) { - evict -= 1 - continue + for (const [id, task] of prior) { + if (!task.backgrounded && task.liveInTurn && !roster.has(id)) { + combined.set(id, task) } - combined.set(id, task) } + // Preserve the provider's roster order. for (const [id, task] of roster) { combined.set(id, task) } diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index b23602b0fff2..031af23c3b04 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -513,7 +513,7 @@ describe('ClaudeBackgroundTaskTracker', () => { } }) - it('admits unknown background updates conservatively and bounds edge-only fallback ids', () => { + it('admits unknown background updates conservatively and tracks every edge-only start', () => { const tracker = trackerAt([100]) tracker.observe( system('task_updated', { task_id: 'unknown', patch: { is_backgrounded: true } }) @@ -529,20 +529,19 @@ describe('ClaudeBackgroundTaskTracker', () => { }) ) } - expect(tracker.stoppableTaskIds.length).toBeLessThanOrEqual(256) - expect(tracker.goalBackgroundWork).toBe('live') + expect(tracker.stoppableTaskIds).toHaveLength(401) tracker.observe(system('task_notification', { task_id: 'unknown', status: 'completed' })) - for (let index = 0; index < 256; index += 1) { + for (let index = 0; index < 399; index += 1) { tracker.observe( system('task_notification', { task_id: `task-${index}`, status: 'completed' }) ) } - expect(tracker.goalBackgroundWork).toBe('unverifiable') - tracker.observe(aggregate([])) + expect(tracker.goalBackgroundWork).toBe('live') + tracker.observe(system('task_notification', { task_id: 'task-399', status: 'completed' })) expect(tracker.goalBackgroundWork).toBe('exited') }) - it('bounds aggregate rosters and resets to the edge-only fallback on clear', () => { + it('lists every aggregate roster entry and resets to the edge-only fallback on clear', () => { const tracker = trackerAt([100]) tracker.observe( aggregate( @@ -553,7 +552,7 @@ describe('ClaudeBackgroundTaskTracker', () => { })) ) ) - expect(tracker.stoppableTaskIds).toHaveLength(256) + expect(tracker.stoppableTaskIds).toHaveLength(400) tracker.clear() tracker.observe( @@ -649,7 +648,7 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(rows(tracker)).toEqual([{ id: 'back-1' }]) }) - it('drops a backgrounded start the roster no longer lists but bounds what it retains', () => { + it('drops a stale backgrounded start and retains every live foreground row', () => { const tracker = new ClaudeBackgroundTaskTracker() tracker.observe({ type: 'user' }, true) for (let index = 0; index < 300; index += 1) { @@ -660,12 +659,8 @@ describe('ClaudeBackgroundTaskTracker', () => { ) const ids = rows(tracker).map((row) => row.id) - // 255 retained foreground rows plus the roster's own entry: retention is - // real and still counts against the cap. - expect(ids).toHaveLength(256) - // When the cap bites, the STALEST retained row goes, not the newest. - expect(ids).toContain('fore-299') - expect(ids).not.toContain('fore-44') + expect(ids).toHaveLength(301) + expect(ids).toContain('fore-0') expect(ids).toContain('back-1') // Aggregate authority over its OWN class is unchanged. diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 77d3b298aea7..c59b41d03450 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -31,7 +31,8 @@ export { } from './claude-background-task-frames' export type { ClaudeBackgroundTaskKind } from './claude-background-task-frames' -const MAX_TRACKED_TASKS = 256 +/** Ended ids remembered to refuse a late start; live tasks take no bound. */ +const MAX_TERMINAL_TASK_IDS = 256 export type ClaudeGoalBackgroundWork = 'live' | 'unverifiable' | 'exited' export class ClaudeBackgroundTaskTracker { @@ -245,10 +246,6 @@ export class ClaudeBackgroundTaskTracker { coverageIncomplete = true continue } - if (!roster.has(id) && roster.size >= MAX_TRACKED_TASKS) { - coverageIncomplete = true - continue - } // An authoritative live roster supersedes an earlier terminal edge โ€” for // the ids it actually lists. Wiping the whole set left a finished // FOREGROUND id undefended, since the start guard now convicts only @@ -268,7 +265,7 @@ export class ClaudeBackgroundTaskTracker { }) } this.coverageIncomplete = coverageIncomplete - for (const [id, task] of combineClaudeBackgroundTaskRoster(prior, roster, MAX_TRACKED_TASKS)) { + for (const [id, task] of combineClaudeBackgroundTaskRoster(prior, roster)) { this.tasks.set(id, task) } for (const [id, task] of prior) { @@ -279,22 +276,6 @@ export class ClaudeBackgroundTaskTracker { } private upsert(id: string, task: Omit): void { - if (!this.tasks.has(id) && this.tasks.size >= MAX_TRACKED_TASKS) { - let foregroundId: string | undefined - for (const [candidateId, candidate] of this.tasks) { - if (!candidate.backgrounded) { - foregroundId = candidateId - break - } - } - if (!foregroundId) { - if (task.backgrounded) { - this.coverageIncomplete = true - } - return - } - this.tasks.delete(foregroundId) - } const existing = this.tasks.get(id) ?? this.retention.resume(id) this.terminalTaskIds.delete(id) if (existing) { @@ -318,7 +299,7 @@ export class ClaudeBackgroundTaskTracker { this.tasks.delete(id) this.terminalTaskIds.delete(id) this.terminalTaskIds.add(id) - if (this.terminalTaskIds.size > MAX_TRACKED_TASKS) { + if (this.terminalTaskIds.size > MAX_TERMINAL_TASK_IDS) { const oldest = this.terminalTaskIds.values().next() if (!oldest.done) { this.terminalTaskIds.delete(oldest.value) diff --git a/src/main/claude/claude-child-work-decoder.test.ts b/src/main/claude/claude-child-work-decoder.test.ts index 084fd6eac0e1..49351c061e9b 100644 --- a/src/main/claude/claude-child-work-decoder.test.ts +++ b/src/main/claude/claude-child-work-decoder.test.ts @@ -154,6 +154,13 @@ describe('Claude child-work decoder', () => { status: 'completed' }) ) + // However many other children ended meanwhile, the wake still reads as this run's restart. + for (let index = 0; index < 300; index += 1) { + const id = `other-${index}` + decoder.observe({ ...backgroundAgent, task_id: id, tool_use_id: `toolu_${index}` }) + decoder.observe(system('task_notification', { task_id: id, status: 'completed' })) + } + decoder.drain(400) decoder.observe(backgroundAgent) decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' })) expect(decoder.drain(500)).toEqual([ @@ -208,7 +215,7 @@ describe('Claude child-work decoder', () => { expect(decoder.drain(500)).toEqual([]) }) - it('admits every live child: only the host store bounds them', () => { + it('admits every live child', () => { const decoder = new ClaudeChildWorkDecoder() for (let index = 0; index < 300; index += 1) { decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` }) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 6cdab01d8f34..341cca0c3e89 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -36,10 +36,6 @@ import { type ClaudeTaskFacts } from './claude-child-work-evidence' -/** Ended ids remembered to recognise a restart. Live children take no bound here: refusing one - * would hide a running child; the host's global limits bound them. */ -const MAX_ENDED_TASKS = 256 - type DecodedClaudeTask = { kind: AgentChildWorkKind backgrounded: boolean @@ -85,7 +81,8 @@ function observation( export class ClaudeChildWorkDecoder { private readonly live = new Map() - /** Ended task ids, with the spawn call each ended under and how. */ + /** Ended task ids, with the spawn call each ended under and how. Kept for the session: a + * forgotten id would read a wake under the same spawn call as a settled child. */ private readonly ended = new Map< string, { run: string | undefined; outcome: AgentChildWorkOutcome } @@ -266,14 +263,7 @@ export class ClaudeChildWorkDecoder { const task = this.live.get(id) this.live.delete(id) const outcome = claudeChildWorkOutcome(status) - this.ended.delete(id) this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome }) - if (this.ended.size > MAX_ENDED_TASKS) { - const [oldest] = this.ended.keys() - if (oldest !== undefined) { - this.ended.delete(oldest) - } - } this.pending.push((observedAt) => ({ type: 'ended', observedAt, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 749bc8258497..095689ecfd60 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -286,9 +286,9 @@ export async function acquireClaudeSession({ sessions, attempt, translator, - prompts + prompts, + releaseAcquisition: () => acquisitions.deleteIfCurrent(sessionId, attempt) }) - acquisitions.deleteIfCurrent(sessionId, attempt) throw acquisitionError } finally { attempt.finish() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 84b4804cd696..d375ce188fe5 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -262,6 +262,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || + this.sessions.has(sessionId) || + this.exits.has(sessionId) /** A close clears the session's tasks outside `emit`; its ending still reaches the host. */ private async afterClose(sessionId: string, close: () => Promise): Promise { diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 316d90b0a509..947b68fa847d 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -43,6 +43,7 @@ export function claudeAcquisitionCleanupError( : new AgentSessionAcquisitionExitUnprovenError(cause) } +/** An unproven exit keeps the acquisition so a later stop can still prove its root gone. */ export async function resolveClaudeAcquisitionError(input: { error: unknown sessionId: string @@ -50,6 +51,7 @@ export async function resolveClaudeAcquisitionError(input: { attempt: ClaudeAcquisitionAttempt translator: ClaudeJournalTranslator | null prompts: ClaudePromptRegistry + releaseAcquisition: () => void }): Promise { let acquisitionError = input.error if (input.sessions.get(input.sessionId)?.connection !== input.attempt.connection) { @@ -64,6 +66,9 @@ export async function resolveClaudeAcquisitionError(input: { acquisitionError = claudeAcquisitionCleanupError(input.attempt.connection, input.error) } } + if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) { + input.releaseAcquisition() + } return acquisitionError } @@ -283,6 +288,10 @@ export async function closeClaudeSession(input: { ) // Why: cancellation must preserve the same actionable verdict as published-session close. if (!(cleanupError instanceof AgentSessionAcquisitionExitUnprovenError)) { + // A proved-dead attempt cannot remain the owner blocking the next start. + if (attempt) { + input.acquisitions.deleteIfCurrent(input.sessionId, attempt) + } throw cleanupError } return false diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3f90182acd41..5090001308c0 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -796,18 +796,14 @@ describe('openCodexAppServerConnection', () => { }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdout.write(`${JSON.stringify(frame)}\n`) expect((await inFlight).message).toContain('structured sink failed') - expect(exits).toEqual([expect.stringContaining('structured sink failed')]) + expect(exits).toHaveLength(0) expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toEqual([expect.stringContaining('structured sink failed')]) await connection.close() }) @@ -869,20 +865,16 @@ describe('openCodexAppServerConnection', () => { { onExit: (error) => exits.push(error.message) }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdin.emit('error', new Error('write EPIPE')) expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(1) + expect(exits).toHaveLength(0) // A child nobody can write to is not a live session: the owner must see the // connection as gone rather than keep issuing calls that can only time out. expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toHaveLength(1) expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') await connection.close() }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 2d46889027ce..17ad6e85795a 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -51,7 +51,7 @@ export async function openCodexAppServerConnection( site: 'codex-app-server-teardown', ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) }) - const { child, terminateTree: terminateProcessTree } = managed + const { child } = managed let nextRequestId = 1 let closing = false @@ -71,7 +71,7 @@ export async function openCodexAppServerConnection( writeResponse, onProtocolFailure: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) @@ -108,7 +108,7 @@ export async function openCodexAppServerConnection( return } handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() }) const recordReader = createProviderRecordReader({ @@ -129,7 +129,7 @@ export async function openCodexAppServerConnection( }, onFatal: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) diff --git a/src/main/codex/codex-background-command-tracker.test.ts b/src/main/codex/codex-background-command-tracker.test.ts index f720a92a44c9..ade16f23705e 100644 --- a/src/main/codex/codex-background-command-tracker.test.ts +++ b/src/main/codex/codex-background-command-tracker.test.ts @@ -52,25 +52,6 @@ describe('persistent command ownership', () => { expect(tracker.tasks()).toEqual([]) }) - it('preflights finite metadata capacity and admits work again after process completion', () => { - const tracker = new CodexBackgroundCommandTracker('root', 700) - const first = command('item/started', 'first') - const second = command('item/started', 'second') - expect(tracker.canObserve(first)).toBe(true) - tracker.observe(first) - expect(tracker.canObserve(second)).toBe(false) - expect(() => tracker.observe(second)).toThrow('not admitted') - expect(tracker.threadCommands('root')).toHaveLength(1) - expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) - tracker.observe(command('item/completed', 'first')) - expect(tracker.canObserve(second)).toBe(true) - tracker.observe(second) - expect(tracker.threadCommands('root')).toHaveLength(1) - expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) - tracker.clear() - expect(tracker.retainedMetadataBytes).toBe(0) - }) - it('keeps the journal running across turn completion and accepts late output and exit', () => { const rows: { key: string; body: AgentJournalItemBody }[] = [] const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 4327ce1f2621..65d352424ecf 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -6,7 +6,6 @@ import { import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' import { readRecord, readString } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' -import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' import type { CodexAbandonedCommand } from './codex-prompt-registry' import { readCodexTurnId } from './codex-structured-thread-facts' import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreground-commands' @@ -20,7 +19,6 @@ type Command = { task: AgentSessionBackgroundTask /** Codex's id for the command's process, which a background-terminal stop names. */ processId?: string - bytes: number } /** A command process starting, or ending: it exited, its thread closed, or the session ended. */ @@ -37,29 +35,9 @@ export type CodexBackgroundCommandChange = export class CodexBackgroundCommandTracker { private readonly commands = new Map() - private readonly settled = new Map() - private liveBytes = 0 - private settledBytes = 0 + private readonly settled = new Set() - constructor( - private readonly primaryThreadId: string, - private readonly maxMetadataBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES - ) {} - - get retainedMetadataBytes(): number { - return this.liveBytes + this.settledBytes - } - - canObserve(event: CodexBackgroundTaskEvent): boolean { - const parsed = this.parse(event) - return ( - !parsed || - parsed.completed || - this.commands.has(parsed.key) || - this.settled.has(parsed.key) || - this.liveBytes + parsed.command.bytes <= this.maxMetadataBytes - ) - } + constructor(private readonly primaryThreadId: string) {} observe(event: CodexBackgroundTaskEvent): CodexBackgroundCommandChange | null { const parsed = this.parse(event) @@ -73,12 +51,7 @@ export class CodexBackgroundCommandTracker { if (this.commands.has(key)) { return null } - if (this.liveBytes + command.bytes > this.maxMetadataBytes) { - throw new Error('Codex command metadata was not admitted before observation') - } this.commands.set(key, command) - this.liveBytes += command.bytes - this.trimSettled() return this.liveChange(command) } @@ -176,8 +149,6 @@ export class CodexBackgroundCommandTracker { ) this.commands.clear() this.settled.clear() - this.liveBytes = 0 - this.settledBytes = 0 return ended } @@ -185,7 +156,6 @@ export class CodexBackgroundCommandTracker { private end(key: string): CodexBackgroundCommandChange | null { const existing = this.commands.get(key) if (existing) { - this.liveBytes -= existing.bytes this.commands.delete(key) } this.rememberSettled(key) @@ -199,28 +169,12 @@ export class CodexBackgroundCommandTracker { } private rememberSettled(key: string): void { - if (this.settled.has(key)) { - return - } - const bytes = Buffer.byteLength(key, 'utf8') + 256 - if (this.liveBytes + bytes <= this.maxMetadataBytes) { - this.settled.set(key, bytes) - this.settledBytes += bytes - } - this.trimSettled() - } - - private trimSettled(): void { - while ( - this.settled.size > MAX_SETTLED_COMMANDS || - this.retainedMetadataBytes > this.maxMetadataBytes - ) { - const oldest = this.settled.entries().next().value - if (!oldest) { - break + this.settled.add(key) + if (this.settled.size > MAX_SETTLED_COMMANDS) { + const oldest = this.settled.values().next().value + if (oldest !== undefined) { + this.settled.delete(oldest) } - this.settled.delete(oldest[0]) - this.settledBytes -= oldest[1] } } @@ -242,7 +196,7 @@ export class CodexBackgroundCommandTracker { .trim() const turnId = readCodexTurnId(event.params) const processId = readString(item, 'processId') ?? undefined - const value = { + const command = { threadId: event.threadId, turnId, backgrounded: turnId !== null && this.settled.has(this.turnKey(event.threadId, turnId)), @@ -256,14 +210,6 @@ export class CodexBackgroundCommandTracker { }, ...(processId !== undefined ? { processId } : {}) } - return { - key, - completed, - command: { - ...value, - bytes: - Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(value), 'utf8') + 256 - } - } + return { key, completed, command } } } diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts index 69e0f11e4d54..cf531f0f59e4 100644 --- a/src/main/codex/codex-background-task-tracker.ts +++ b/src/main/codex/codex-background-task-tracker.ts @@ -133,10 +133,6 @@ export class CodexBackgroundTaskTracker { return this.publishedState } - canObserve(event: CodexBackgroundTaskEvent): boolean { - return this.commands.canObserve(event) - } - /** `unapproved`: commands whose approval the journal dropped with this frame's turn ending. */ observe( event: CodexBackgroundTaskEvent, diff --git a/src/main/codex/codex-child-work-evidence.test.ts b/src/main/codex/codex-child-work-evidence.test.ts index 00023b11bac3..b53cdfb7ed40 100644 --- a/src/main/codex/codex-child-work-evidence.test.ts +++ b/src/main/codex/codex-child-work-evidence.test.ts @@ -548,7 +548,7 @@ describe('Codex child-work evidence', () => { expect(byKind('command')).toEqual([expect.objectContaining({ membership: 'live' })]) }) - it('leaves no record behind a finished shell, so none displaces a finished child', () => { + it('leaves no record behind a finished shell', () => { const { send, byKind, records, log } = runningChild() send(turn('turn/completed', CHILD, 'c1')) const before = log.length diff --git a/src/main/codex/codex-child-work-evidence.ts b/src/main/codex/codex-child-work-evidence.ts index 9569acac65c6..c8f3f27dfa68 100644 --- a/src/main/codex/codex-child-work-evidence.ts +++ b/src/main/codex/codex-child-work-evidence.ts @@ -34,9 +34,6 @@ import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thre import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity' import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions' -/** Facts kept for settled children; a working child always has its own. */ -const MAX_CHILD_FACTS = 128 -const MAX_OPEN_CALLS_PER_CHILD = 16 const CHILD_FRAME_METHODS: ReadonlySet = new Set([ 'item/started', 'item/completed', @@ -196,9 +193,6 @@ export class CodexChildWorkEvidence { const call = codexChildToolCall(item) if (call && !facts.openCalls.has(item.id)) { facts.openCalls.set(item.id, { ...call, turnId }) - for (const stale of [...facts.openCalls.keys()].slice(0, -MAX_OPEN_CALLS_PER_CHILD)) { - facts.openCalls.delete(stale) - } } } @@ -304,15 +298,6 @@ export class CodexChildWorkEvidence { if (existing) { return existing } - if (this.facts.size >= MAX_CHILD_FACTS) { - // Refusing a working child its facts would drop its every edge; the host's limits bound them. - const idle = [...this.facts.keys()].find( - (id) => this.executions.find(id)?.execution?.state !== 'working' - ) - if (idle !== undefined) { - this.facts.delete(idle) - } - } const facts: ChildFacts = { openCalls: new Map(), waiting: false } this.facts.set(threadId, facts) return facts diff --git a/src/main/codex/codex-item-stream-retention.ts b/src/main/codex/codex-item-stream-retention.ts index ace12f346671..f21ba0ba0a98 100644 --- a/src/main/codex/codex-item-stream-retention.ts +++ b/src/main/codex/codex-item-stream-retention.ts @@ -5,35 +5,23 @@ import { } from './codex-structured-item-stream-bounds' import type { CodexItemStreamState } from './codex-structured-item-stream-contracts' -// Preserve the previous metadata ceiling while letting small live commands share it. -export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES = - MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES +// Bounds only evictable streams; a command that outlives its turn is kept until it ends. +const MAX_EVICTABLE_STATE_BYTES = MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean } export class CodexItemStreamRetention { private readonly states = new Map() - private bytes = 0 - private persistentBytes = 0 + private evictableBytes = 0 private persistentCount = 0 - constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {} - - get retainedBytes(): number { - return this.bytes - } - - get size(): number { - return this.states.size - } - get persistentSize(): number { return this.persistentCount } get overCapacity(): boolean { return ( - this.bytes > this.maxBytes || + this.evictableBytes > MAX_EVICTABLE_STATE_BYTES || this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES ) } @@ -46,30 +34,16 @@ export class CodexItemStreamRetention { return this.states.get(key)?.persistent === true } - canRetain(key: string, state: CodexItemStreamState): boolean { - const previous = this.states.get(key) - return ( - this.persistentBytes - - (previous?.persistent ? previous.bytes : 0) + - this.stateBytes(key, state) <= - this.maxBytes - ) - } - - retain(key: string, state: CodexItemStreamState): boolean { - if (!this.canRetain(key, state)) { - return false - } + retain(key: string, state: CodexItemStreamState): void { this.forget(key) const bytes = this.stateBytes(key, state) const persistent = codexCommandOutlivesTurn(state.item) this.states.set(key, { state, bytes, persistent }) - this.bytes += bytes if (persistent) { - this.persistentBytes += bytes this.persistentCount += 1 + } else { + this.evictableBytes += bytes } - return true } oldestEvictable(): string | undefined { @@ -86,18 +60,17 @@ export class CodexItemStreamRetention { if (!entry) { return } - this.bytes -= entry.bytes if (entry.persistent) { - this.persistentBytes -= entry.bytes this.persistentCount -= 1 + } else { + this.evictableBytes -= entry.bytes } this.states.delete(key) } clear(): void { this.states.clear() - this.bytes = 0 - this.persistentBytes = 0 + this.evictableBytes = 0 this.persistentCount = 0 } diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 5c70b2bbcf60..223969989431 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -3,10 +3,6 @@ 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 { CodexBackgroundCommandTracker } from './codex-background-command-tracker' -import { - CodexItemStreamRetention, - MAX_CODEX_ITEM_STREAM_METADATA_BYTES -} from './codex-item-stream-retention' import { CodexJournalItems } from './codex-structured-journal-items' import { settleCodexJournalTurn } from './codex-structured-journal-settlement' @@ -29,7 +25,7 @@ function command(threadId: string, id: string, method = 'item/started') { } } -function fixture(maxMetadataBytes?: number) { +function fixture() { const rows = new Map() const scheduled = new Set<() => void>() const sink = { @@ -45,7 +41,6 @@ function fixture(maxMetadataBytes?: number) { const items = new CodexJournalItems( { sink, - maxMetadataBytes, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: (run) => { scheduled.add(run) @@ -99,7 +94,6 @@ describe('persistent command retention', () => { Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`)) ).flat() for (const event of events) { - expect(tracker.canObserve(event)).toBe(true) expect(items.handle(event)).toMatchObject({ admission: { accepted: true } }) tracker.observe(event) expect( @@ -134,7 +128,6 @@ describe('persistent command retention', () => { expect(items.activeItems.size).toBe(448) expect(items.streams.persistentCount).toBe(448) expect(tracker.tasks()).toHaveLength(448) - expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024) for (const event of events) { items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', { turnId: 'turn', @@ -165,50 +158,10 @@ describe('persistent command retention', () => { expect(items.activeItems.size).toBe(0) expect(items.streams.persistentCount).toBe(0) expect(tracker.tasks()).toEqual([]) - expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024) items.dispose() - tracker.clear() - expect(tracker.retainedMetadataBytes).toBe(0) expect(scheduled.size).toBe(0) }) - it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => { - const { items, rows } = fixture(800) - const first = command('root', 'first') - const second = command('root', 'second') - expect(items.handle(first)).toMatchObject({ admission: { accepted: true } }) - const prior = [...rows] - expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } }) - expect([...rows]).toEqual(prior) - expect(items.activeItems.size).toBe(1) - expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({ - admission: { accepted: true } - }) - expect(items.handle(second)).toMatchObject({ admission: { accepted: true } }) - items.dispose() - }) - - it('accounts metadata bytes instead of interpreting the item count as liveness', () => { - const retention = new CodexItemStreamRetention() - for (let index = 0; index < 448; index += 1) { - const item = command('root', `exec-${index}`).params.item - expect( - retention.retain(item.id, { - item, - identity: { provider: 'orca', clientMessageId: item.id } - }) - ).toBe(true) - } - expect(retention.size).toBe(448) - expect(retention.retainedBytes).toBeLessThan(256 * 1024) - expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES) - expect(retention.overCapacity).toBe(false) - expect(retention.oldestEvictable()).toBeUndefined() - retention.clear() - expect(retention.retainedBytes).toBe(0) - expect(retention.persistentSize).toBe(0) - }) - it('retains startup provenance when large command metadata is bounded', () => { const { items, sink } = fixture() const event = command('root', 'large') diff --git a/src/main/codex/codex-structured-item-stream-contracts.ts b/src/main/codex/codex-structured-item-stream-contracts.ts index 38b9a9d3038a..af61e3357b61 100644 --- a/src/main/codex/codex-structured-item-stream-contracts.ts +++ b/src/main/codex/codex-structured-item-stream-contracts.ts @@ -17,7 +17,6 @@ export type CodexItemStreamDeps = { coalesceMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number - maxMetadataBytes?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } @@ -44,14 +43,13 @@ export type CodexStructuredItemStreamHandleResult = { export type CodexStructuredItemStreams = { readonly persistentCount: number - canTrack: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean track: ( threadId: string, turnId: string | null, item: CodexThreadItem, identity: AgentJournalItemIdentity, startedAt?: number - ) => boolean + ) => void handle: ( threadId: string, method: string, diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index c783458ca298..261b8eea2178 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -48,7 +48,7 @@ export { export function createCodexStructuredItemStreams( deps: CodexItemStreamDeps ): CodexStructuredItemStreams { - const states = new CodexItemStreamRetention(deps.maxMetadataBytes) + const states = new CodexItemStreamRetention() const checkpointLengths = new Map() const pendingCheckpoints = new Set() // Which thread and turn produced each stream, resolved to its attribution per append @@ -158,7 +158,7 @@ export function createCodexStructuredItemStreams( itemId: string, type: string, params: unknown - ): CodexItemStreamState | null => { + ): CodexItemStreamState => { const key = codexStructuredItemKey(threadId, itemId) const existing = states.get(key) if (existing) { @@ -167,9 +167,7 @@ export function createCodexStructuredItemStreams( const item = { type, id: itemId } const turnId = deps.turnIdFor(threadId, params) const state = { item, identity: deps.identityFor(threadId, turnId, item) } - if (!states.retain(key, state)) { - return null - } + states.retain(key, state) producers.set(key, { threadId, turnId }) trimStates() return state @@ -214,19 +212,11 @@ export function createCodexStructuredItemStreams( get persistentCount() { return states.persistentSize }, - canTrack: (threadId, item, identity) => - states.canRetain( - codexStructuredItemKey(threadId, item.id), - codexItemStreamState(item, identity) - ), track: (threadId, turnId, item, identity, startedAt) => { const key = codexStructuredItemKey(threadId, item.id) - if (!states.retain(key, codexItemStreamState(item, identity, startedAt))) { - return false - } + states.retain(key, codexItemStreamState(item, identity, startedAt)) producers.set(key, { threadId, turnId }) trimStates() - return true }, handle: (threadId, method, params) => { const paramsRecord = readCodexItemStreamRecord(params) @@ -243,9 +233,6 @@ export function createCodexStructuredItemStreams( const key = codexStructuredItemKey(threadId, itemId) const streamFlushed = coalescer.flush(key) const state = ensureState(threadId, itemId, 'fileChange', params) - if (!state) { - return { handled: true, admission: { accepted: false, reason: 'failed' } } - } state.item = { ...state.item, changes: paramsRecord.changes } const translated = codexJournalItem(state.item) if (translated.body) { @@ -285,9 +272,6 @@ export function createCodexStructuredItemStreams( return { handled: true, admission: { accepted: true } } } const state = ensureState(threadId, itemId, type ?? 'reasoning', params) - if (!state) { - return { handled: true, admission: { accepted: false, reason: 'failed' } } - } const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta if (typeof delta === 'string') { const key = codexStructuredItemKey(threadId, state.item.id) diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 17a002225e7c..85db92a578e5 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -54,7 +54,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'now' - > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, + > & { attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void, private readonly helperName?: CodexHelperName @@ -64,7 +64,6 @@ export class CodexJournalItems { coalesceMs: deps.coalesceMs, maxRetainedBytes: deps.maxRetainedBytes, schedule: deps.schedule, - maxMetadataBytes: deps.maxMetadataBytes, turnIdFor: (threadId, params) => readCodexTurnId(params) ?? this.activeTurn(threadId), identityFor: (threadId, turnId, item) => this.identityFor(threadId, turnId, item), attributionFor: deps.attributionFor @@ -101,12 +100,6 @@ export class CodexJournalItems { if (item.type === 'contextCompaction' && event.method === 'item/started') { return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } - if ( - event.method !== 'item/completed' && - !this.streams.canTrack(event.threadId, item, identity) - ) { - return { handled: true, admission: { accepted: false, reason: 'failed' } } - } const itemKey = codexStructuredItemKey(event.threadId, item.id) const active = event.method === 'item/completed' ? this.activeItems.get(itemKey) : undefined const receivedAt = event.observedAt ?? this.deps.now?.() ?? Date.now() diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 2a1b3d92fce2..19b600618048 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -76,9 +76,6 @@ export class CodexJournalTurnBoundaries { if (!turnId) { return CODEX_JOURNAL_ADMITTED } - if (!this.deps.activeTurns.canRemember(event.threadId, turnId)) { - return { accepted: false, reason: 'backpressure' } - } const startedAt = this.receiptTime(event) const command = event.threadId === this.deps.primaryThreadId() ? this.deps.commands.claim(turnId) : null diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.test.ts b/src/main/codex/codex-structured-journal-translation-turn-state.test.ts index dda4eaca0635..bd3699cbe228 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.test.ts @@ -2,53 +2,16 @@ import { describe, expect, it } from 'vitest' import { CodexJournalActiveTurns, CodexJournalRecentTurns, - MAX_CODEX_ACTIVE_TURN_BYTES, - MAX_CODEX_ACTIVE_TURNS, MAX_CODEX_RECENT_TURN_BYTES, MAX_CODEX_RECENT_TURNS } from './codex-structured-journal-translation-turn-state' describe('CodexJournalActiveTurns', () => { - it('refuses new turns at the bounded active capacity without evicting live state', () => { - const active = new CodexJournalActiveTurns() - for (let index = 0; index < MAX_CODEX_ACTIVE_TURNS; index += 1) { - expect(active.remember(`thread-${index}`, `turn-${index}`)).toBe(true) - } - - expect(active.remember('thread-overflow', 'turn-overflow')).toBe(false) - expect(active.size).toBe(MAX_CODEX_ACTIVE_TURNS) - expect(active.byThread.size).toBe(MAX_CODEX_ACTIVE_TURNS) - expect(active.current('thread-0')).toBe('turn-0') - expect(active.current(`thread-${MAX_CODEX_ACTIVE_TURNS - 1}`)).toBe( - `turn-${MAX_CODEX_ACTIVE_TURNS - 1}` - ) - }) - - it('admits a new turn after an earlier turn settles', () => { - const active = new CodexJournalActiveTurns() - for (let index = 0; index < MAX_CODEX_ACTIVE_TURNS; index += 1) { - active.remember('thread', `turn-${index}`) - } - active.forget('thread', 'turn-0') - - expect(active.remember('thread', 'turn-new')).toBe(true) - expect(active.size).toBe(MAX_CODEX_ACTIVE_TURNS) - expect(active.current('thread')).toBe('turn-new') - }) - - it('refuses provider identifiers that would exceed the aggregate byte bound', () => { - const active = new CodexJournalActiveTurns() - - expect(active.remember('thread', 'x'.repeat(MAX_CODEX_ACTIVE_TURN_BYTES))).toBe(false) - expect(active.size).toBe(0) - expect(active.bytes).toBe(0) - }) - it('remembers each turn start time until the turn is forgotten', () => { const active = new CodexJournalActiveTurns() - expect(active.remember('thread', 'turn-1', 1_000)).toBe(true) - expect(active.remember('thread', 'turn-1', 2_000)).toBe(true) + active.remember('thread', 'turn-1', 1_000) + active.remember('thread', 'turn-1', 2_000) expect(active.startedAt('thread', 'turn-1')).toBe(1_000) expect(active.startedAt('thread', 'turn-missing')).toBeUndefined() diff --git a/src/main/codex/codex-structured-journal-translation-turn-state.ts b/src/main/codex/codex-structured-journal-translation-turn-state.ts index f42abd4b26fd..1146a73e0add 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-state.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-state.ts @@ -1,8 +1,6 @@ import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import type { CodexDispatchRequestOrigin } from './codex-structured-dispatch-echo' -export const MAX_CODEX_ACTIVE_TURNS = 256 -export const MAX_CODEX_ACTIVE_TURN_BYTES = 256 * 1024 export const MAX_CODEX_RECENT_TURNS = 256 export const MAX_CODEX_RECENT_TURN_BYTES = 256 * 1024 @@ -15,9 +13,8 @@ function earlierRequestOrigin( return current === undefined || candidate.sequence < current.sequence } +/** Running turns across provider threads; each is forgotten when it ends, all at session end. */ export class CodexJournalActiveTurns { - /** Bounds active turn keys retained across provider threads. */ - static readonly MAX_ENTRIES = MAX_CODEX_ACTIVE_TURNS readonly byThread = new Map>() /** Host turn-start receipt per remembered turn; the terminal row carries it forward. */ private readonly startedAtByTurn = new Map() @@ -25,34 +22,11 @@ export class CodexJournalActiveTurns { private readonly requestOriginByTurn = new Map() /** Last dispatch armed before each provider turn-start event. */ private readonly latestDispatchSequenceByTurn = new Map() - private activeCount = 0 - private retainedBytes = 0 - - get size(): number { - return this.activeCount - } - - get bytes(): number { - return this.retainedBytes - } private turnKey(threadId: string, turnId: string): string { return `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` } - private entryBytes(threadId: string, turnId: string): number { - return Buffer.byteLength(threadId, 'utf8') + Buffer.byteLength(turnId, 'utf8') - } - - canRemember(threadId: string, turnId: string): boolean { - const active = this.byThread.get(threadId) - return ( - active?.has(turnId) === true || - (this.activeCount < CodexJournalActiveTurns.MAX_ENTRIES && - this.retainedBytes + this.entryBytes(threadId, turnId) <= MAX_CODEX_ACTIVE_TURN_BYTES) - ) - } - current(threadId: string): string | null { return [...(this.byThread.get(threadId) ?? [])].at(-1) ?? null } @@ -113,13 +87,10 @@ export class CodexJournalActiveTurns { turnId: string, startedAt?: number, latestDispatchSequence = Number.MAX_SAFE_INTEGER - ): boolean { + ): void { const active = this.byThread.get(threadId) if (active?.has(turnId)) { - return true - } - if (!this.canRemember(threadId, turnId)) { - return false + return } if (startedAt !== undefined) { this.startedAtByTurn.set(this.turnKey(threadId, turnId), startedAt) @@ -130,9 +101,6 @@ export class CodexJournalActiveTurns { } else { this.byThread.set(threadId, new Set([turnId])) } - this.activeCount += 1 - this.retainedBytes += this.entryBytes(threadId, turnId) - return true } forget(threadId: string, turnId: string): void { @@ -140,10 +108,7 @@ export class CodexJournalActiveTurns { this.requestOriginByTurn.delete(this.turnKey(threadId, turnId)) this.latestDispatchSequenceByTurn.delete(this.turnKey(threadId, turnId)) const active = this.byThread.get(threadId) - if (active?.delete(turnId)) { - this.activeCount -= 1 - this.retainedBytes = Math.max(0, this.retainedBytes - this.entryBytes(threadId, turnId)) - } + active?.delete(turnId) if (!active?.size) { this.byThread.delete(threadId) } @@ -154,8 +119,6 @@ export class CodexJournalActiveTurns { this.startedAtByTurn.clear() this.requestOriginByTurn.clear() this.latestDispatchSequenceByTurn.clear() - this.activeCount = 0 - this.retainedBytes = 0 } } diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 908eb86cbad3..3d417bcff662 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -10,7 +10,6 @@ import { type StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' -import { MAX_CODEX_ACTIVE_TURNS } from './codex-structured-journal-translation-turn-state' import { CODEX_COMMAND_APPROVAL_METHOD, CODEX_USER_INPUT_METHOD @@ -111,31 +110,6 @@ function hardWatermarkDeferred() { } describe('codex journal translation', () => { - it('refuses an active-turn overflow before publishing an un-settleable lifecycle row', () => { - const tap = recorder() - const translator = createCodexJournalTranslator({ - sink: tap.sink, - primaryThreadId: () => THREAD_ID - }) - - for (let index = 0; index < MAX_CODEX_ACTIVE_TURNS; index += 1) { - expect( - translator.handle(notification('turn/started', { turn: { id: `turn-${index}` } })) - ).toEqual({ accepted: true }) - } - expect( - translator.handle(notification('turn/started', { turn: { id: 'turn-overflow' } })) - ).toEqual({ accepted: false, reason: 'backpressure' }) - expect(tap.rows.filter((row) => row.body.kind === 'turn')).toHaveLength(MAX_CODEX_ACTIVE_TURNS) - - expect(translator.handle(notification('turn/completed', { turn: { id: 'turn-0' } }))).toEqual({ - accepted: true - }) - expect( - translator.handle(notification('turn/started', { turn: { id: 'turn-overflow' } })) - ).toEqual({ accepted: true }) - }) - it('projects turns restored by thread/resume into durable conversation rows', () => { const { translator, tap } = translatorWith() diff --git a/src/main/codex/codex-structured-real-cli-command-env.test.ts b/src/main/codex/codex-structured-real-cli-command-env.test.ts new file mode 100644 index 000000000000..401684247e29 --- /dev/null +++ b/src/main/codex/codex-structured-real-cli-command-env.test.ts @@ -0,0 +1,84 @@ +// The installed Codex under a user policy that keeps only core env vars: a user shell command +// (`thread/shellCommand`) must still see the chat's identity, on a new thread and on a cold resume. +// The model's exec tool reads the same `shell_environment_policy.set` in Codex's source. Opt-in +// (ORCA_REAL_CODEX_CLI_TEST=1); no account is used, nothing reaches a model. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { openCodexThread } from './codex-structured-thread-open' +import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy' + +// The probe is a POSIX shell command. +const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' && process.platform !== 'win32' +const SESSION_ID = 'codex_real_command_env' +const PROBE = 'printf "probe[%s]" "$ORCA_AGENT_SESSION_ID"' + +async function probeIdentity( + base: string, + thread: { resumeThreadId: string | null; commandEnv?: Record } +): Promise<{ threadId: string; seen: string }> { + let output = '' + let turnCompleted = false + const cwd = join(base, 'workspace') + const connection = await openCodexAppServerConnection( + { + command: resolveCodexCommand(), + args: ['app-server'], + cwd, + env: { HOME: base, CODEX_HOME: join(base, 'codex-home'), ORCA_AGENT_SESSION_ID: SESSION_ID } + }, + { + onNotification: (method, params) => { + output += JSON.stringify(params) + turnCompleted ||= method === 'turn/completed' + } + } + ) + try { + const opened = await openCodexThread( + connection, + { cwd, permissionPolicy: codexStructuredPermissionPolicy('ask'), ...thread }, + 20_000 + ) + await connection.request( + 'thread/shellCommand', + { threadId: opened.threadId, command: PROBE }, + { timeoutMs: 10_000 } + ) + // Until the turn completes Codex may not have saved the rollout the cold resume reads. + const deadline = Date.now() + 15_000 + while (!turnCompleted && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + const seen = output.match(/probe\[([^\]%]*)\]/) + return { threadId: opened.threadId, seen: seen?.[1] ?? '' } + } finally { + await connection.close() + } +} + +describe.skipIf(!enabled)('Codex real app-server command env', () => { + it('keeps the chat identity in commands under inherit = "core", on start and resume', async () => { + const base = mkdtempSync(join(tmpdir(), 'orca-real-codex-env-')) + try { + mkdirSync(join(base, 'workspace'), { recursive: true }) + mkdirSync(join(base, 'codex-home'), { recursive: true }) + writeFileSync( + join(base, 'codex-home', 'config.toml'), + '[shell_environment_policy]\ninherit = "core"\n' + ) + const commandEnv = { ORCA_AGENT_SESSION_ID: SESSION_ID } + // The policy really strips it: the app-server holds the id, its command does not. + expect((await probeIdentity(base, { resumeThreadId: null })).seen).toBe('') + const started = await probeIdentity(base, { resumeThreadId: null, commandEnv }) + expect(started.seen).toBe(SESSION_ID) + const resumed = await probeIdentity(base, { resumeThreadId: started.threadId, commandEnv }) + expect(resumed).toEqual({ threadId: started.threadId, seen: SESSION_ID }) + } finally { + rmSync(base, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 7d400dab145b..88e30f4d1625 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -24,6 +24,7 @@ import { } from './codex-structured-owner-identity' import { openCodexThread } from './codex-structured-thread-open' import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { NATIVE_CHAT_SCRATCH_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' import { closeCodexPublishedSession, handleCodexSessionExit @@ -193,7 +194,15 @@ export async function acquireCodexStructuredSession(input: { ...(deps.logger ? { logger: deps.logger } : {}) }) acquisitions.assertCurrent(sessionId, attempt) - const opened = await openCodexThread(connection, threadLaunch, deps.requestTimeoutMs) + const commandEnv = { + ...launch.commandEnv, + ...(launch.visuals ? { [NATIVE_CHAT_SCRATCH_DIR_ENV]: launch.visuals.folder } : {}) + } + const opened = await openCodexThread( + connection, + { ...threadLaunch, commandEnv }, + deps.requestTimeoutMs + ) acquisitions.assertCurrent(sessionId, attempt) primaryThreadId = opened.threadId const restoreAdmission = translator?.restoreThread(opened.threadId, opened.thread ?? {}) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 6ed9144c6e9b..9ed195bfd24b 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -171,7 +171,19 @@ describe('CodexStructuredSessionAdapter.acquire', () => { cwd: '/work/repo', approvalPolicy: 'on-request', sandbox: 'workspace-write', - approvalsReviewer: 'user' + approvalsReviewer: 'user', + // A user's `inherit = "core"` policy would strip these from every command the chat runs. + config: { + 'shell_environment_policy.set.ORCA_AGENT_SESSION_ID': 'session-1', + 'shell_environment_policy.set.ORCA_STRUCTURED_SESSION': '1', + 'shell_environment_policy.set.ORCA_CLI_COMMAND': expect.stringMatching( + /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ + ), + 'shell_environment_policy.set.ORCA_USER_DATA_PATH': expect.any(String), + 'shell_environment_policy.set.ORCA_ENVIRONMENT': '', + 'shell_environment_policy.set.ORCA_PAIRING_CODE': '', + 'shell_environment_policy.set.ORCA_REMOTE_PAIRING': '' + } } }) expect(acquisition.process).toEqual({ diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 50cb90a45f9f..5f72bb5da387 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -124,9 +124,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session: CodexSession, event: CodexStructuredSessionEvent ): CodexJournalTranslationAdmission { - if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) { - return { accepted: false, reason: 'failed' } - } if (event.type === 'notification') { noteCodexTurnOpened(session, event.method, event.params) } @@ -334,6 +331,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || this.sessions.has(sessionId) private session(sessionId: string): CodexSession { return requireLiveCodexSession(this.sessions, 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 466cadce8a13..8dfc3a159142 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -7,7 +7,7 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexJournalItems } from './codex-structured-journal-items' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -173,7 +173,7 @@ describe("codex background tasks reach the host's child records", () => { await adapter.closeSession('session-1') }) - it('recovers the exact provider generation when command metadata cannot be admitted', async () => { + it('recovers the exact provider generation when a notification cannot be admitted', async () => { const published: Published[] = [] const observed: CodexStructuredSessionEvent[] = [] const appendItem = vi.fn() @@ -185,15 +185,15 @@ describe("codex background tasks reach the host's child records", () => { appendItem.mockClear() observed.length = 0 const admission = vi - .spyOn(CodexBackgroundTaskTracker.prototype, 'canObserve') - .mockReturnValue(false) + .spyOn(CodexJournalItems.prototype, 'handle') + .mockReturnValue({ handled: true, admission: { accepted: false, reason: 'failed' } }) try { codex.handlers().onNotification?.('item/started', { threadId: THREAD_ID, turnId: PARENT_TURN, item: { type: 'commandExecution', - id: 'over-budget', + id: 'refused', command: 'sleep 1', source: 'unifiedExecStartup', status: 'inProgress' @@ -220,6 +220,41 @@ describe("codex background tasks reach the host's child records", () => { } }) + it('publishes a child whose turn starts while 256 others are running', async () => { + const published: Published[] = [] + const { adapter, codex } = await adapterWithSession(published, { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + }) + const startChild = (index: number): void => { + const childId = `child-${index}` + codex.handlers().onNotification?.('turn/started', { + threadId: childId, + turn: { id: `turn-${index}`, status: 'inProgress' } + }) + codex.handlers().onNotification?.('item/started', { + threadId: THREAD_ID, + turnId: PARENT_TURN, + item: { + type: 'subAgentActivity', + id: `call-${index}`, + kind: 'started', + agentThreadId: childId, + agentPath: `/root/worker_${index}` + } + }) + } + // The parent and its first child already hold two running turns. + for (let index = 0; index < 254; index += 1) { + startChild(index) + } + published.length = 0 + startChild(254) + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['live'] }]) + await adapter.closeSession('session-1') + }) + it('publishes the fan-out while it runs and keeps it past the spawning turn', async () => { const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 7a253003bd96..ae04e846c005 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -48,6 +48,8 @@ export type CodexStructuredLaunch = { /** The child's sealed environment, and the inherited keys it must not keep. */ env?: Record envToDelete?: readonly string[] + /** The session values each command must see, whatever the user's shell environment policy. */ + commandEnv?: Record /** This chat's scratch folder and visuals skill; absent when the chat has no visuals. */ visuals?: NativeChatVisualsLaunch } diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index c6e686869b7a..653955b4d8c4 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -45,6 +45,19 @@ function isCodexNoRolloutError(error: unknown, threadId: string): boolean { ) } +/** + * Codex rebuilds each command's env from `shell_environment_policy`, so `inherit = "core"` drops + * every Orca key the app-server holds. Codex adds `set` keys after that filter and merges these + * per key, so the user's own policy stays as written. + */ +function shellEnvironmentSetConfig( + env: Record | undefined +): Record { + return Object.fromEntries( + Object.entries(env ?? {}).map(([key, value]) => [`shell_environment_policy.set.${key}`, value]) + ) +} + export async function openCodexThread( connection: Pick, launch: { @@ -58,14 +71,17 @@ export async function openCodexThread( model?: string /** Config overrides for this thread, on start and resume alike. */ threadConfig?: Record + /** Env every command the agent runs must see, whatever the user's shell environment policy. */ + commandEnv?: Record }, timeoutMs: number | undefined ): Promise { const resumeThreadId = launch.resumeThreadId + const config = { ...launch.threadConfig, ...shellEnvironmentSetConfig(launch.commandEnv) } const threadSettings = { cwd: launch.cwd, ...launch.permissionPolicy, - ...(launch.threadConfig ? { config: launch.threadConfig } : {}) + ...(Object.keys(config).length > 0 ? { config } : {}) } const startThread = (): Promise => connection.request( diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index 46b381c4ac74..b4be28302115 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -207,7 +207,10 @@ describe('a Codex chat acquisition with visuals', () => { expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open)) expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({ ...MANUAL, - config: { 'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder] } + config: { + 'sandbox_workspace_write.writable_roots': ['/home/me/scratch', VISUALS.folder], + [`shell_environment_policy.set.${NATIVE_CHAT_SCRATCH_DIR_ENV}`]: VISUALS.folder + } }) expect(connection.launch.env?.[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(VISUALS.folder) } @@ -220,7 +223,11 @@ describe('a Codex chat acquisition with visuals', () => { const methods = connection.calls.map((call) => call.method) expect(methods[0]).toBe('thread/start') expect(methods).not.toContain('skills/extraRoots/set') - expect(connection.calls[0]?.params).not.toHaveProperty('config') + // Only the chat's identity overrides: no visuals root, and no inherited folder handed on. + const config = connection.calls[0]?.params?.config + const scratchDirOverride = `shell_environment_policy.set.${NATIVE_CHAT_SCRATCH_DIR_ENV}` + expect(config).not.toHaveProperty(['sandbox_workspace_write.writable_roots']) + expect(config).not.toHaveProperty([scratchDirOverride]) expect(connection.launch.env).not.toHaveProperty(NATIVE_CHAT_SCRATCH_DIR_ENV) }) }) diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts index d298d2d351af..319ddb427ab9 100644 --- a/src/main/codex/codex-subagent-executions.test.ts +++ b/src/main/codex/codex-subagent-executions.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' describe('CodexSubagentExecutions retention and identity', () => { - it('bounds settled history through repeated execution without evicting live children', () => { + it('bounds settled turn history through repeated execution without evicting live children', () => { const executions = new CodexSubagentExecutions() executions.register('long-lived', 'long-lived') executions.observeTurn('long-lived', 'long-lived-turn', 'working') @@ -13,12 +14,10 @@ describe('CodexSubagentExecutions retention and identity', () => { executions.observeTurn(id, id, 'completed') } expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived']) - const { children, settledTurns } = executions.retentionSizes() - expect(children).toBeLessThanOrEqual(128) - expect(settledTurns).toBeLessThanOrEqual(256) + expect(executions.retentionSizes().settledTurns).toBeLessThanOrEqual(256) }) - it('keeps every running child past the settled-history bound', () => { + it('keeps every child, running or ended, however many run', () => { const executions = new CodexSubagentExecutions() for (let index = 0; index < 200; index++) { expect(executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')).not.toBeNull() @@ -26,36 +25,42 @@ describe('CodexSubagentExecutions retention and identity', () => { } expect(executions.workingChildren()).toHaveLength(200) executions.observeTurn('child-0', 'turn-0', 'completed') - executions.register('next', 'next') - expect(executions.find('child-0')).toBeUndefined() + expect(executions.find('child-0')).toBeDefined() expect(executions.workingChildren()).toHaveLength(199) }) - it('evicts a child that ended before one announced but not yet started, which keeps its spawner', () => { + it('targets live descendants through settled ancestors without losing historical lineage order', () => { const executions = new CodexSubagentExecutions() - executions.register('announced', 'announced', 'spawner') - for (let index = 0; index < 127; index++) { - executions.observeTurn(`child-${index}`, `turn-${index}`, 'working') + executions.register('root', 'root', 'primary') + executions.observeTurn('root', 'root-turn', 'working') + const finished = Array.from({ length: 10_000 }, (_, index) => `finished-${index}`) + for (const id of finished) { + executions.register(id, id, 'root') + executions.observeTurn(id, `${id}-turn`, 'completed') } - executions.observeTurn('child-5', 'turn-5', 'completed') - executions.register('next', 'next') - expect(executions.find('child-5')).toBeUndefined() - expect(executions.find('announced')).toMatchObject({ - registered: true, - spawnerThreadId: 'spawner' - }) - executions.observeTurn('announced', 'turn-a', 'working') - expect(executions.workingChildren().map((child) => child.agentThreadId)).toContain('announced') - }) + executions.register('settled-parent', 'settled parent', 'root') + executions.observeTurn('settled-parent', 'settled-turn', 'completed') + executions.register('live-descendant', 'live descendant', 'settled-parent') + executions.observeTurn('live-descendant', 'live-turn', 'working') + executions.register('sibling', 'sibling', 'root') + executions.observeTurn('sibling', 'sibling-turn', 'working') - it('past the bound, evicts the oldest never-started child once no child has ended', () => { - const executions = new CodexSubagentExecutions() - for (let index = 0; index < 200; index++) { - executions.register(`restored-${index}`, `restored-${index}`) - } - expect(executions.retentionSizes().children).toBe(128) - expect(executions.find('restored-0')).toBeUndefined() - expect(executions.find('restored-199')).toBeDefined() + expect(executions.lineage('root').map((child) => child.agentThreadId)).toEqual([ + 'root', + 'sibling', + 'settled-parent', + 'live-descendant', + ...finished.toReversed() + ]) + const tracker = new CodexBackgroundTaskTracker('primary', executions) + expect(tracker.subagentStopTargets(['root'])).toEqual({ + turns: [ + { threadId: 'root', turnId: 'root-turn' }, + { threadId: 'sibling', turnId: 'sibling-turn' }, + { threadId: 'live-descendant', turnId: 'live-turn' } + ], + terminals: [] + }) }) it('collapses a labelโ€™s whitespace, and keeps the first label a child was given', () => { diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts index bd9623821bbb..dc155bc99bb3 100644 --- a/src/main/codex/codex-subagent-executions.ts +++ b/src/main/codex/codex-subagent-executions.ts @@ -4,9 +4,6 @@ import { canReplaceSubagentState } from '../../shared/native-chat-subagent-summary' -/** Children kept past this evict the oldest that ran and ended, else the oldest never started; a - * running child is always kept, however many run. */ -const MAX_CHILDREN = 128 const MAX_SETTLED_TURNS = 256 /** Turn ordinals remembered per child; a row from an older run reads as its first. */ const MAX_TURN_ORDINALS_PER_CHILD = 64 @@ -123,6 +120,15 @@ export class CodexSubagentExecutions { /** The child and every child it spawned, at any depth. */ lineage(agentThreadId: string): CodexExecutionChild[] { + const spawnedByThread = new Map() + for (const child of this.children.values()) { + if (child.spawnerThreadId === null) { + continue + } + const siblings = spawnedByThread.get(child.spawnerThreadId) ?? [] + siblings.push(child.agentThreadId) + spawnedByThread.set(child.spawnerThreadId, siblings) + } const lineage: CodexExecutionChild[] = [] const seen = new Set() const pending = [agentThreadId] @@ -133,10 +139,8 @@ export class CodexSubagentExecutions { } seen.add(id) lineage.push(child) - for (const spawned of this.children.values()) { - if (spawned.spawnerThreadId === id) { - pending.push(spawned.agentThreadId) - } + for (const spawned of spawnedByThread.get(id) ?? []) { + pending.push(spawned) } } return lineage @@ -163,17 +167,6 @@ export class CodexSubagentExecutions { if (existing) { return existing } - if (this.children.size >= MAX_CHILDREN) { - // Refusing a running child would hide it while it runs. One announced but not started is - // about to run, so it goes only when no child has ended; a restored history announces many. - const children = [...this.children.values()] - const evicted = - children.find((child) => child.execution !== null && child.execution.state !== 'working') ?? - children.find((child) => child.execution === null) - if (evicted) { - this.children.delete(evicted.agentThreadId) - } - } const child: CodexExecutionChild = { agentThreadId, registered: false, diff --git a/src/main/crash-reporting/minidump-crash-signature.ts b/src/main/crash-reporting/minidump-crash-signature.ts index f122f2b1eb8e..4c6cab77043c 100644 --- a/src/main/crash-reporting/minidump-crash-signature.ts +++ b/src/main/crash-reporting/minidump-crash-signature.ts @@ -14,9 +14,14 @@ import { findStream, isMinidump, MinidumpView, type MinidumpSource } from './minidump-stream-reader' import { readCrashpadAnnotations } from './minidump-crashpad-annotations' import { findEmbeddedCheckMessage } from './minidump-embedded-check' +import { readMinidumpFaultContext, type MinidumpFaultContext } from './minidump-fault-context' const STREAM_TYPE_MODULE_LIST = 4 const STREAM_TYPE_EXCEPTION = 6 +const STREAM_TYPE_SYSTEM_INFO = 7 +// MINIDUMP_SYSTEM_INFO.PlatformId; Crashpad writes its own ids for macOS and Linux. +const SYSTEM_INFO_PLATFORM_ID_OFFSET = 20 +const VER_PLATFORM_WIN32_NT = 2 const MODULE_RECORD_SIZE = 108 // 8x headroom over a measured 1042-image macOS renderer, whose whole list a @@ -45,6 +50,7 @@ export type MinidumpCrashSignature = { /** Module whose image range contains `exceptionAddress`. */ readonly faultingModule?: string readonly faultingModuleOffset?: string + readonly fault?: MinidumpFaultContext /** Allowlisted Crashpad annotations, verbatim. */ readonly annotations: Readonly> } @@ -79,6 +85,14 @@ async function readModules(view: MinidumpView): Promise { return modules } +async function isWindowsDump(view: MinidumpView): Promise { + const stream = await findStream(view, STREAM_TYPE_SYSTEM_INFO) + return ( + stream !== null && + (await view.u32(stream.rva + SYSTEM_INFO_PLATFORM_ID_OFFSET)) === VER_PLATFORM_WIN32_NT + ) +} + function moduleBasename(modulePath: string): string { const separator = Math.max(modulePath.lastIndexOf('/'), modulePath.lastIndexOf('\\')) return separator >= 0 ? modulePath.slice(separator + 1) : modulePath @@ -187,6 +201,12 @@ export async function parseMinidumpCrashSignature( signature.faultingModule = faulting.name signature.faultingModuleOffset = faulting.offset } + // Code bytes only from a loaded module in a Windows dump: they exist to diff against the + // shipped binary. JIT memory adds nothing, and on macOS/Linux Crashpad records the touched + // data address here, not the instruction pointer. + signature.fault = await readMinidumpFaultContext(view, exception.rva, code, address, { + captureCodeBytes: faulting !== undefined && (await isWindowsDump(view)) + }) } } @@ -222,6 +242,19 @@ export function minidumpSignatureDetails( if (signature.faultingModuleOffset) { details.minidumpFaultingModuleOffset = signature.faultingModuleOffset } + const fault = signature.fault + if (fault?.access) { + details.minidumpExceptionAccess = fault.access + } + if (fault?.faultAddress) { + details.minidumpFaultAddress = fault.faultAddress + } + if (fault?.faultStackPointerDelta !== undefined) { + details.minidumpFaultStackPointerDelta = fault.faultStackPointerDelta + } + if (fault?.codeBytes) { + details.minidumpCodeBytes = fault.codeBytes + } for (const [key, value] of Object.entries(signature.annotations)) { if (key === 'LOG_FATAL' || key === 'abort-message' || key === 'ptype') { continue diff --git a/src/main/crash-reporting/minidump-fault-context.test.ts b/src/main/crash-reporting/minidump-fault-context.test.ts new file mode 100644 index 000000000000..c6621f1bedd5 --- /dev/null +++ b/src/main/crash-reporting/minidump-fault-context.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import { minidumpSignatureDetails, parseMinidumpCrashSignature } from './minidump-crash-signature' + +const STREAM_TYPE_MODULE_LIST = 4 +const STREAM_TYPE_MEMORY_LIST = 5 +const STREAM_TYPE_SYSTEM_INFO = 7 +const VER_PLATFORM_WIN32_NT = 2 +const CRASHPAD_PLATFORM_MACOS = 0x8101 +const STREAM_TYPE_EXCEPTION = 6 +const CONTEXT_SIZE = 0x4d0 +// Bytes Electron 43.7.8 x64 ships at electron.exe+0x23d3f88: movb %dil, 0x88(%r12) twice. +const CODE = Buffer.from('4188bc24880000004188bc2490000000', 'hex') + +type FaultDump = { + code: number + rip: bigint + params?: bigint[] + rsp?: bigint + contextFlags?: number + memory?: { start: bigint; bytes: Buffer }[] + modules?: { base: bigint; size: number; name: string }[] + platformId?: number +} + +/** Exception stream, x64 CONTEXT, MemoryList and ModuleList laid out as Crashpad writes them. */ +function buildFaultDump(options: FaultDump): Buffer { + const streamCount = + 1 + + (options.memory ? 1 : 0) + + (options.modules ? 1 : 0) + + (options.platformId === undefined ? 0 : 1) + let cursor = 32 + streamCount * 12 + const regions: Buffer[] = [] + const append = (buf: Buffer): number => { + const rva = cursor + regions.push(buf) + cursor += buf.length + return rva + } + + const context = Buffer.alloc(CONTEXT_SIZE) + context.writeUInt32LE(options.contextFlags ?? 0x0010001f, 0x30) + context.writeBigUInt64LE(options.rsp ?? 0n, 0x98) + const contextRva = append(context) + + const exception = Buffer.alloc(168) + exception.writeUInt32LE(7, 0) + exception.writeUInt32LE(options.code, 8) + exception.writeBigUInt64LE(options.rip, 24) + const params = options.params ?? [] + exception.writeUInt32LE(params.length, 32) + params.forEach((value, index) => exception.writeBigUInt64LE(value, 40 + index * 8)) + exception.writeUInt32LE(CONTEXT_SIZE, 160) + exception.writeUInt32LE(contextRva, 164) + const streams = [{ type: STREAM_TYPE_EXCEPTION, size: 168, rva: append(exception) }] + + if (options.memory) { + const dataRvas = options.memory.map((range) => append(range.bytes)) + const list = Buffer.alloc(4 + options.memory.length * 16) + list.writeUInt32LE(options.memory.length, 0) + options.memory.forEach((range, index) => { + list.writeBigUInt64LE(range.start, 4 + index * 16) + list.writeUInt32LE(range.bytes.length, 12 + index * 16) + list.writeUInt32LE(dataRvas[index], 16 + index * 16) + }) + streams.push({ type: STREAM_TYPE_MEMORY_LIST, size: list.length, rva: append(list) }) + } + + if (options.modules) { + const nameRvas = options.modules.map((module) => { + const name = Buffer.from(module.name, 'utf16le') + const length = Buffer.alloc(4) + length.writeUInt32LE(name.length, 0) + return append(Buffer.concat([length, name])) + }) + const list = Buffer.alloc(4 + options.modules.length * 108) + list.writeUInt32LE(options.modules.length, 0) + options.modules.forEach((module, index) => { + list.writeBigUInt64LE(module.base, 4 + index * 108) + list.writeUInt32LE(module.size, 12 + index * 108) + list.writeUInt32LE(nameRvas[index], 24 + index * 108) + }) + streams.push({ type: STREAM_TYPE_MODULE_LIST, size: list.length, rva: append(list) }) + } + + if (options.platformId !== undefined) { + const systemInfo = Buffer.alloc(56) + systemInfo.writeUInt32LE(options.platformId, 20) + streams.push({ type: STREAM_TYPE_SYSTEM_INFO, size: 56, rva: append(systemInfo) }) + } + + const header = Buffer.alloc(32 + streamCount * 12) + header.writeUInt32LE(0x504d444d, 0) + header.writeUInt32LE(streamCount, 8) + header.writeUInt32LE(32, 12) + streams.forEach((stream, index) => { + header.writeUInt32LE(stream.type, 32 + index * 12) + header.writeUInt32LE(stream.size, 36 + index * 12) + header.writeUInt32LE(stream.rva, 40 + index * 12) + }) + return Buffer.concat([header, ...regions]) +} + +describe('minidump fault context', () => { + it('reports a write AV into the faulting frame and the executed bytes', async () => { + const rip = 0x7ff6_3941_3f88n + const rsp = 0x8a_3a03_d000n + const dump = buildFaultDump({ + code: 0xc0000005, + rip, + rsp, + params: [1n, rsp + 0x3c8n], + memory: [ + { start: rip - 1n, bytes: Buffer.concat([Buffer.from([0x80]), CODE, Buffer.from([0x4c])]) } + ], + platformId: VER_PLATFORM_WIN32_NT, + modules: [{ base: rip - 0x23d3f88n, size: 0x9000000, name: 'C:\\Orca\\Orca.exe' }] + }) + + const signature = await parseMinidumpCrashSignature(dump) + const details = minidumpSignatureDetails(signature!) + + expect(details).toMatchObject({ + minidumpExceptionCode: '0xc0000005', + minidumpExceptionAccess: 'write', + minidumpFaultAddress: '0x8a3a03d3c8', + minidumpFaultStackPointerDelta: 0x3c8, + minidumpCodeBytes: CODE.toString('hex') + }) + }) + + it('omits the stack delta for a fault far from the stack and for non-x64 contexts', async () => { + const far = await parseMinidumpCrashSignature( + buildFaultDump({ code: 0xc0000005, rip: 0x1000n, rsp: 0x10_0000_0000n, params: [0n, 0x20n] }) + ) + expect(far?.fault).toEqual({ access: 'read', faultAddress: '0x20' }) + + const arm = await parseMinidumpCrashSignature( + buildFaultDump({ + code: 0xc0000005, + rip: 0x1000n, + rsp: 0x30n, + contextFlags: 0x00400000, + params: [8n, 0x20n] + }) + ) + expect(arm?.fault).toEqual({ access: 'execute', faultAddress: '0x20' }) + }) + + it('keeps only code bytes for non-AV exceptions and truncates at the captured range', async () => { + const signature = await parseMinidumpCrashSignature( + buildFaultDump({ + code: 0x80000003, + rip: 0x5000n, + params: [1n, 0x20n], + memory: [ + { start: 0x100n, bytes: Buffer.from('aa', 'hex') }, + { start: 0x4ffen, bytes: Buffer.from('9090cc0f0b', 'hex') } + ], + platformId: VER_PLATFORM_WIN32_NT, + modules: [{ base: 0x4000n, size: 0x2000, name: 'Orca.exe' }] + }) + ) + expect(signature?.fault).toEqual({ codeBytes: 'cc0f0b' }) + }) + + it('captures no code bytes outside a loaded module, such as JIT code', async () => { + const signature = await parseMinidumpCrashSignature( + buildFaultDump({ + code: 0xc0000005, + rip: 0x9000n, + rsp: 0x10_0000_0000n, + params: [0n, 0x20n], + memory: [{ start: 0x9000n, bytes: CODE }], + platformId: VER_PLATFORM_WIN32_NT, + modules: [{ base: 0x4000n, size: 0x2000, name: 'Orca.exe' }] + }) + ) + expect(signature?.fault).toEqual({ access: 'read', faultAddress: '0x20' }) + }) + + it('captures no code bytes from a macOS dump, where the address is the touched data', async () => { + const signature = await parseMinidumpCrashSignature( + buildFaultDump({ + code: 0x1, + rip: 0x5000n, + memory: [{ start: 0x5000n, bytes: CODE }], + platformId: CRASHPAD_PLATFORM_MACOS, + modules: [{ base: 0x4000n, size: 0x2000, name: 'Electron Framework' }] + }) + ) + expect(signature?.fault).toEqual({}) + }) + + it('degrades to nothing when the AV parameters or memory list are missing', async () => { + const signature = await parseMinidumpCrashSignature( + buildFaultDump({ code: 0xc0000005, rip: 0x5000n }) + ) + expect(signature?.exceptionAddress).toBe('0x5000') + expect(signature?.fault).toEqual({}) + }) +}) diff --git a/src/main/crash-reporting/minidump-fault-context.ts b/src/main/crash-reporting/minidump-fault-context.ts new file mode 100644 index 000000000000..9166cb11207e --- /dev/null +++ b/src/main/crash-reporting/minidump-fault-context.ts @@ -0,0 +1,128 @@ +// What a native fault touched and which bytes were executing. +// +// Why: the faulting RVA alone cannot separate an engine bug from a corrupted +// code page or unstable CPU. Two Windows renderer AVs mapped to official +// symbols as writes into memory the preceding instructions had just written; +// the code bytes Crashpad captured at the instruction pointer settle that by +// comparison with the shipped binary. + +import { findStream, type MinidumpView } from './minidump-stream-reader' + +const STREAM_TYPE_MEMORY_LIST = 5 +const EXCEPTION_ACCESS_VIOLATION = 0xc0000005 + +// MINIDUMP_EXCEPTION_STREAM: ThreadId, alignment, MINIDUMP_EXCEPTION (152 bytes), ThreadContext. +const NUMBER_PARAMETERS_OFFSET = 8 + 24 +const EXCEPTION_INFORMATION_OFFSET = 8 + 32 +const THREAD_CONTEXT_OFFSET = 8 + 152 + +// x64 CONTEXT; other architectures report no stack-pointer delta. +const AMD64_CONTEXT_SIZE = 0x4d0 +const AMD64_CONTEXT_FLAGS_OFFSET = 0x30 +const CONTEXT_AMD64 = 0x00100000 +const AMD64_RSP_OFFSET = 0x98 + +const MEMORY_DESCRIPTOR_SIZE = 16 +const MAX_MEMORY_RANGES = 16_384 +const CODE_BYTES = 16 +// Beyond this the fault is not a slot in the faulting frame, so the delta adds nothing. +const MAX_STACK_POINTER_DELTA = 1024 * 1024 + +const ACCESS_KINDS: Readonly> = { + 0: 'read', + 1: 'write', + 8: 'execute' +} + +export type MinidumpFaultContext = { + readonly access?: 'read' | 'write' | 'execute' + readonly faultAddress?: string + /** Fault address minus the faulting thread's stack pointer, when within 1 MiB. */ + readonly faultStackPointerDelta?: number + /** Hex of the bytes at the instruction pointer, as captured in the dump. */ + readonly codeBytes?: string +} + +async function readAmd64StackPointer( + view: MinidumpView, + exceptionRva: number +): Promise { + const context = await view.location(exceptionRva + THREAD_CONTEXT_OFFSET) + if (!context || context.size < AMD64_CONTEXT_SIZE) { + return null + } + const flags = await view.u32(context.rva + AMD64_CONTEXT_FLAGS_OFFSET) + if (flags === null || (flags & CONTEXT_AMD64) === 0) { + return null + } + return view.u64(context.rva + AMD64_RSP_OFFSET) +} + +async function readCapturedBytes(view: MinidumpView, address: bigint): Promise { + const stream = await findStream(view, STREAM_TYPE_MEMORY_LIST) + if (!stream) { + return null + } + const count = await view.u32(stream.rva) + if (count === null || count > MAX_MEMORY_RANGES) { + return null + } + for (let index = 0; index < count; index += 1) { + const descriptor = stream.rva + 4 + index * MEMORY_DESCRIPTOR_SIZE + const start = await view.u64(descriptor) + const range = await view.location(descriptor + 8) + if (start === null) { + return null + } + if (!range || address < start || address >= start + BigInt(range.size)) { + continue + } + const skip = Number(address - start) + const size = Math.min(CODE_BYTES, range.size - skip) + const bytes = await view.bytes({ size, rva: range.rva + skip }, CODE_BYTES) + return bytes ? bytes.toString('hex') : null + } + return null +} + +export async function readMinidumpFaultContext( + view: MinidumpView, + exceptionRva: number, + exceptionCode: number | null, + exceptionAddress: bigint, + options: { readonly captureCodeBytes: boolean } +): Promise { + const result: { -readonly [K in keyof MinidumpFaultContext]: MinidumpFaultContext[K] } = {} + const codeBytes = options.captureCodeBytes + ? await readCapturedBytes(view, exceptionAddress) + : null + if (codeBytes) { + result.codeBytes = codeBytes + } + if (exceptionCode !== EXCEPTION_ACCESS_VIOLATION) { + return result + } + const parameterCount = await view.u32(exceptionRva + NUMBER_PARAMETERS_OFFSET) + if (parameterCount === null || parameterCount < 2) { + return result + } + const kind = await view.u64(exceptionRva + EXCEPTION_INFORMATION_OFFSET) + const faultAddress = await view.u64(exceptionRva + EXCEPTION_INFORMATION_OFFSET + 8) + const access = kind === null ? undefined : ACCESS_KINDS[Number(kind)] + if (access) { + result.access = access + } + if (faultAddress === null) { + return result + } + result.faultAddress = `0x${faultAddress.toString(16)}` + const stackPointer = await readAmd64StackPointer(view, exceptionRva) + if (stackPointer !== null) { + const delta = faultAddress - stackPointer + const magnitude = delta < 0n ? -delta : delta + if (magnitude <= BigInt(MAX_STACK_POINTER_DELTA)) { + result.faultStackPointerDelta = Number(delta) + } + } + return result +} diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts index 5c29e8a9e311..aa46e8b1a0cc 100644 --- a/src/main/daemon/daemon-foreground-process-protocol.ts +++ b/src/main/daemon/daemon-foreground-process-protocol.ts @@ -20,5 +20,7 @@ export type InspectProcessRequest = Omit & expectedIncarnationId?: string /** Optional; a daemon that predates it answers with the full capture as it always did. */ steadyState?: boolean + /** Optional; a daemon that predates it keeps counting detached daemons as children. */ + ignoreDetached?: boolean } } diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index 0c10a2a2c857..c52388a84651 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -7,7 +7,10 @@ import { type ListSessionsResult, type SessionInfo } from './types' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import type { + PtyProcessInspection, + PtyProcessInspectionOptions +} from '../providers/pty-process-inspection' import { clientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { @@ -25,7 +28,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot async inspectProcess( id: string, - options?: { expectedIncarnationId?: string; steadyState?: boolean } + options?: PtyProcessInspectionOptions ): Promise { if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { return clientOnlyUnverifiableInspection('old_host') @@ -49,7 +52,8 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot ? { expectedIncarnationId: options.expectedIncarnationId } : {}), // Additive: an older daemon ignores it and pays for the full capture. - ...(options?.steadyState === true ? { steadyState: true } : {}) + ...(options?.steadyState === true ? { steadyState: true } : {}), + ...(options?.ignoreDetached === true ? { ignoreDetached: true } : {}) }) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 069dab3a731c..0009cb356bba 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -226,7 +226,7 @@ export class DaemonPtyRouter implements IPtyProvider { async inspectProcess( id: string, - options?: { expectedIncarnationId?: string; steadyState?: boolean } + options?: Parameters[1] ): Promise { return this.adapterForInspection(id).inspectProcess(id, options) } diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index ac3e8f67f411..73d7703592c2 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -118,7 +118,8 @@ export class DaemonRequestRouter { ...(request.payload.expectedIncarnationId ? { expectedIncarnationId: request.payload.expectedIncarnationId } : {}), - ...(request.payload.steadyState === true ? { steadyState: true } : {}) + ...(request.payload.steadyState === true ? { steadyState: true } : {}), + ...(request.payload.ignoreDetached === true ? { ignoreDetached: true } : {}) } return Object.keys(options).length > 0 ? this.options.host.inspectProcess(request.payload.sessionId, options) diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 3e206ef76e0e..9db9b326d37c 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -67,7 +67,7 @@ function table(command: string | null, loginWrapper = false): ProcessTableRow[] ] } -function createHandle(loginWrapper = false) { +function createHandle(loginWrapper = false, shellPath = '/bin/zsh') { const proc: IPty & { processNameIsSpawnFile: true } = { pid: 100, cols: 80, @@ -86,7 +86,7 @@ function createHandle(loginWrapper = false) { } return createDaemonPtySubprocessHandle({ process: proc, - shellPath: '/bin/zsh', + shellPath, spawnCwd: '/tmp', env: {}, startupCommandDeliveredInShellArgs: false, @@ -262,6 +262,24 @@ it('reports an idle Windows shell only when the owned job contains the shell alo expect(readWindows).not.toHaveBeenCalled() }) +it('accepts only a complete actual-shell Git Bash launch chain', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const shellPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const handle = createHandle(false, shellPath) + members.mockReturnValue(new Set([100, 102])) + readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe', creationTimeMs: 1 }, + { pid: 102, ppid: 100, name: 'bash.exe', creationTimeMs: 2 }]) + expect(await handle.inspectChildProcesses?.()).toBe('no-children') + readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe' }]) + expect(await handle.inspectChildProcesses?.()).toBe('unverifiable') + readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe' }, + { pid: 102, ppid: 100, name: 'agent.exe' }]) + expect(await handle.inspectChildProcesses?.()).toBe('children') + members.mockReturnValue(new Set([102])) + expect(await handle.inspectChildProcesses?.()).toBe('unverifiable') + handle.dispose() +}) + it('keeps the Windows close guard live when a shell descendant is selected above another job', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') resolveWindows.mockResolvedValue({ available: true, processName: null }) diff --git a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts index be70254dc12c..c07e3f77e7a7 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts @@ -10,6 +10,13 @@ import { import type { ProcessTableRow } from '../../../shared/process-table-snapshot' import type { PtyChildProcessVerdict } from '../../../shared/terminal-process-inspection' import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' +import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes' +import { isWindowsShellAloneInJob } from '../../providers/windows-shell-alone-in-job' +import { ptyShellProcessId } from '../../windows/windows-pty-job' +import { + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh +} from '../../windows/windows-process-table' function executableName(command: string): string { return getCommandTokenPathBasename(getFirstCommandToken(command)).replace(/^-/, '') @@ -44,7 +51,36 @@ export function inspectSpawnFileChildProcessesFromRows( : 'no-children' } -export function inspectSpawnFileWindowsChildProcesses(proc: IPty): PtyChildProcessVerdict { - const members = readWindowsPtyJobProcessIds(proc) - return members === null ? 'unverifiable' : members.size > 1 ? 'children' : 'no-children' +export async function inspectSpawnFileWindowsChildProcesses( + proc: IPty, + shellPath: string, + fresh = true, + /** Adoption only: may fork the console-list helper to set aside detached daemons. */ + ignoreDetached = false +): Promise { + try { + const members = readWindowsPtyJobProcessIds(proc) + const shellPid = ptyShellProcessId(proc) + if (!shellPid || !members?.has(shellPid)) { + return 'unverifiable' + } + const alone = await isWindowsShellAloneInJob( + shellPid, + shellPath, + members, + async () => { + const rows = await (fresh + ? readWindowsProcessIdentityTableFresh() + : readWindowsProcessIdentityTable()) + if ([...members].some((pid) => !rows.some((row) => row.pid === pid && row.name))) { + throw new Error('Incomplete shell job identity') + } + return rows + }, + ignoreDetached ? () => readWindowsConsoleAttachedProcessIds(shellPid) : undefined + ) + return alone ? 'no-children' : 'children' + } catch { + return 'unverifiable' + } } diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index 56b739bfccc0..8e8b332787c6 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -29,6 +29,7 @@ export function createDaemonPtySubprocessHandle(args: { startupAgentRecognition: RecognizedAgentProcess | null }): SubprocessHandle { const reportsChildExitStatus = args.reportsChildExitStatus + const shellPath = args.shellPath const proc = args.process // node-pty exposes destroy at runtime but omits it from IPty. const nativeProc: DisposableNativePty = proc @@ -71,7 +72,10 @@ export function createDaemonPtySubprocessHandle(args: { pid: proc.pid, processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc), ...(process.platform === 'win32' - ? { inspectChildProcesses: () => inspectSpawnFileWindowsChildProcesses(proc) } + ? { + inspectChildProcesses: (fresh?: boolean, ignoreDetached?: boolean) => + inspectSpawnFileWindowsChildProcesses(proc, shellPath, fresh, ignoreDetached) + } : {}), shellPath: args.shellPath, shellCwd: args.spawnCwd, diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index be336c998f2a..e8d8d375ca9a 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -3,10 +3,12 @@ import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { JobTerminationOutcome } from '../windows/windows-pty-job' +export type ChildProcessVerdictRead = PtyChildProcessVerdict | Promise + export type SubprocessHandle = { pid: number processNameIsSpawnFile?: boolean - inspectChildProcesses?(): PtyChildProcessVerdict + inspectChildProcesses?(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(options?: { rawFallback?: boolean }): string | null diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 1790eed4066b..845608425caa 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -5,7 +5,7 @@ import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import { SessionTerminationController } from './session-termination-controller' -import type { SubprocessHandle } from './session-subprocess-handle' +import type { ChildProcessVerdictRead, SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' import type { TuiAgent } from '../../shared/tui-agent' @@ -18,7 +18,6 @@ import type { TakePendingOutputResult, TerminalSnapshot } from './types' -import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export class Session { @@ -248,8 +247,8 @@ export class Session { return this.output.getCwd() } - inspectChildProcesses(): PtyChildProcessVerdict { - return this.subprocess.inspectChildProcesses?.() ?? 'unverifiable' + inspectChildProcesses(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead { + return this.subprocess.inspectChildProcesses?.(fresh, ignoreDetached) ?? 'unverifiable' } getForegroundProcess(options?: { rawFallback?: boolean }): string | null { diff --git a/src/main/daemon/terminal-host-non-agent-foreground.test.ts b/src/main/daemon/terminal-host-non-agent-foreground.test.ts index f4150259e5c1..434622ecc08a 100644 --- a/src/main/daemon/terminal-host-non-agent-foreground.test.ts +++ b/src/main/daemon/terminal-host-non-agent-foreground.test.ts @@ -42,10 +42,12 @@ async function inspect(rawName: string, command: string | null) { readSnapshot.mockResolvedValue({ rows: table(command), capturedAgeMs: 0 }) return inspectTerminalHostProcess({ sessionId: 'busy-tab', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This unmarked-foreground fixture supplies the members inspection exercises; daemon lifecycle is outside this test. session: { pid: 100, incarnationId: 'incarnation-1', isAlive: true, + inspectChildProcesses: () => 'unverifiable' as const, getForegroundProcess: () => rawName } as unknown as Session, authorityGeneration: 'generation-1', diff --git a/src/main/daemon/terminal-host-process-inspection.test.ts b/src/main/daemon/terminal-host-process-inspection.test.ts index eca0207dade8..97b51a062533 100644 --- a/src/main/daemon/terminal-host-process-inspection.test.ts +++ b/src/main/daemon/terminal-host-process-inspection.test.ts @@ -23,6 +23,35 @@ function createSubprocess(): SubprocessHandle { } describe('TerminalHost process inspection', () => { + it('proves ordinary Windows shell children and withholds proof if its original Session exits', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const subprocess = createSubprocess() + let exitDuringRead = false + const inspectChildProcesses = vi.fn(async () => { + if (exitDuringRead) { + subprocess.kill() + } + return 'no-children' as const + }) + const host = new TerminalHost({ spawnSubprocess: () => ({ ...subprocess, + getForegroundProcess: () => 'powershell.exe', inspectChildProcesses }) }) + try { + const created = await host.createOrAttach({ sessionId: 'exiting', cols: 80, rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + await expect(host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId })) + .resolves.toMatchObject({ foregroundProcess: 'powershell.exe', childProcessEvidence: 'no-children', + foregroundProcessEvidence: { verdict: 'unverifiable', reason: 'windows_ssh_foreground_unavailable' } }) + expect(inspectChildProcesses).toHaveBeenCalledOnce() + exitDuringRead = true + await expect(host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId })) + .resolves.toMatchObject({ childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable', reason: 'session_changed' } }) + } finally { + await host.dispose() + vi.restoreAllMocks() + } + }) + it('returns unverifiable when the expected incarnation is stale', async () => { const host = new TerminalHost({ spawnSubprocess: () => createSubprocess() }) try { diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index 20abc266ad68..2bb6e0e25d33 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -35,9 +35,12 @@ export type TerminalHostInspectionTier = 'full' | 'cheap' export async function inspectTerminalHostProcess(args: { sessionId: string session: Session | null + currentSession?: () => Session | undefined expectedIncarnationId?: string /** The caller is a self-correcting poll that only reads the process name, never evidence. */ steadyState?: boolean + /** See `PtyProcessInspectionOptions`. */ + ignoreDetached?: boolean retiredIncarnation?: RetiredIncarnation authorityGeneration: string nextObservationEpoch: () => number @@ -90,8 +93,12 @@ export async function inspectTerminalHostProcess(args: { } else if (process.platform === 'win32') { if (session.processNameIsSpawnFile) { foregroundProcess = await session.confirmForegroundProcess() - childProcessEvidence = session.inspectChildProcesses() } + // A steady-state poll never decides adoption or close, so it may read the cached process table. + childProcessEvidence = await session.inspectChildProcesses( + args.steadyState !== true, + args.ignoreDetached === true + ) // Unverifiable whatever `ps` says, and an MSYS `ps.exe` on PATH would flash a console. evidence = unverifiableEvidence(args, session, 'windows_ssh_foreground_unavailable') } else { @@ -127,6 +134,10 @@ export async function inspectTerminalHostProcess(args: { clearSteadyStateAnchor(session) } } + if (!session.isAlive || (args.currentSession && args.currentSession() !== session)) { + childProcessEvidence = 'unverifiable' + evidence = unverifiableEvidence(args, session, 'session_changed') + } const nonShellForeground = foregroundProcess !== null && !isShellProcess(foregroundProcess) // Evidence names recognized agents only, so its null must not erase an ordinary command (#18078). const ordinaryForeground = diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2e0c3b457f26..9a6f5c21c9a8 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,4 +1,5 @@ import type { Session } from './session' +import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' import { SessionNotFoundError } from './types' import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import type { CreateOrAttachResult } from './terminal-host-create-contract' @@ -19,7 +20,6 @@ import { waitForTerminalAttachOperation } from './terminal-attach-cancellation' import { randomUUID } from 'node:crypto' import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations' import { inspectTerminalHostProcess } from './terminal-host-process-inspection' -import type { TerminalHostProcessInspection } from './terminal-host-process-inspection' import { confirmTerminalHostForegroundProcess, confirmTerminalHostShellForeground, @@ -229,8 +229,8 @@ export class TerminalHost { inspectProcess( sessionId: string, - options?: { expectedIncarnationId?: string; steadyState?: boolean } - ): Promise { + options?: PtyProcessInspectionOptions + ): ReturnType { pruneRetiredPtyIncarnations(this.retiredIncarnations) const session = this.sessions.get(sessionId) if ( @@ -245,11 +245,9 @@ export class TerminalHost { } return inspectTerminalHostProcess({ sessionId, + currentSession: () => this.sessions.get(sessionId), session: session?.isAlive ? session : null, - ...(options?.expectedIncarnationId - ? { expectedIncarnationId: options.expectedIncarnationId } - : {}), - ...(options?.steadyState === true ? { steadyState: true } : {}), + ...options, retiredIncarnation: this.retiredIncarnations.get(sessionId), authorityGeneration: this.authorityGeneration, nextObservationEpoch: () => ++this.observationEpoch diff --git a/src/main/destination-serialized-local-rename.ts b/src/main/destination-serialized-local-rename.ts index 74e313c8e989..90a5a41134e4 100644 --- a/src/main/destination-serialized-local-rename.ts +++ b/src/main/destination-serialized-local-rename.ts @@ -11,7 +11,8 @@ function isENOENT(error: unknown): boolean { ) } -async function destinationParentKey(filePath: string): Promise { +/** Canonical parent directory, the lock scope for local mutations that must not interleave. */ +export async function serializedParentDirectoryKey(filePath: string): Promise { const parentPath = dirname(filePath) try { return normalize(await realpath(parentPath)) @@ -29,7 +30,7 @@ export async function renameLocalPathSerializedByDestination( oldPath: string, newPath: string ): Promise { - const key = await destinationParentKey(newPath) + const key = await serializedParentDirectoryKey(newPath) const previous = pendingRenamesByParent.get(key) ?? Promise.resolve() let release!: () => void const current = new Promise((resolve) => { diff --git a/src/main/folder-repo-git-conversion.ts b/src/main/folder-repo-git-conversion.ts index b23a0ad4bd80..dbd6cd57259c 100644 --- a/src/main/folder-repo-git-conversion.ts +++ b/src/main/folder-repo-git-conversion.ts @@ -14,6 +14,7 @@ import { isFolderRepo } from '../shared/repo-kind' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { getGitRepoRootAsync, isGitRepoAsync } from './git/repo' import { findExtraFolderWorkspaceIds } from './folder-workspace-meta-presence' +import { findProjectHostSetup } from '../shared/project-host-setup-target' export type FolderToGitConversion = { updates: Pick @@ -122,7 +123,7 @@ export async function updateProjectHostSetupWithProvenKind( if (args.updates.kind !== 'git') { return updateSetup(args) } - const setup = store.getProjectHostSetups?.().find((entry) => entry.id === args.setupId) + const setup = findProjectHostSetup(store.getProjectHostSetups?.() ?? [], args) const repo = setup?.repoId ? store .getRepos() @@ -141,7 +142,7 @@ export async function updateProjectHostSetupWithProvenKind( if (!result?.repo || !proven.folderUpgradeGitRootPath) { return result } - const stamped = store.updateRepo(result.repo.id, proven, getRepoExecutionHostId(result.repo)) + const stamped = store.updateRepo(result.repo, proven) return stamped ? { ...result, repo: stamped } : result }, allowRuntimeHost diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 37e6a2ed0833..093b938e5527 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -326,6 +326,15 @@ describe('isGitRepo', () => { ).toBe(String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`) }) + it.skipIf(process.platform === 'win32')( + 'keeps a literal backslash in a POSIX checkout root', + () => { + expect(normalizeGitRepoRootForInputPath('/tmp/repo\\name/sub', '/tmp/repo\\name')).toBe( + '/tmp/repo\\name' + ) + } + ) + it('preserves bare repository paths when no worktree root exists', () => { const bareRepo = path.join(tmpDir, 'bare.git') git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts index 9be70187a793..f153fb2789a9 100644 --- a/src/main/git/repo-detection.ts +++ b/src/main/git/repo-detection.ts @@ -234,7 +234,8 @@ export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: st // Why: persist the UNC root so later Git calls keep routing through the WSL runner. return toWindowsWslPath(rootPath, inputWsl.distro) } - return normalizeRuntimePathSeparators(rootPath) + // Why: on POSIX a backslash is a filename character, and git already reports `/` separators. + return process.platform === 'win32' ? normalizeRuntimePathSeparators(rootPath) : rootPath } export function isGitRepo(path: string): boolean { diff --git a/src/main/git/source-control/discard-changes.ts b/src/main/git/source-control/discard-changes.ts index 407e2289db9f..5c9c91625b83 100644 --- a/src/main/git/source-control/discard-changes.ts +++ b/src/main/git/source-control/discard-changes.ts @@ -33,7 +33,7 @@ export async function discardChanges( let tracked = false try { await gitExecFileAsync( - ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], + ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, worktreePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } @@ -44,9 +44,12 @@ export async function discardChanges( } if (tracked) { - await gitExecFileAsync(['restore', '--worktree', '--', literalPathspec(filePath, options)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['restore', '--worktree', '--', literalPathspec(filePath, worktreePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) await redoWriteForDriveCheckoutSuspects(worktreePath, [filePath], DISCARD_WRITE, options) return } @@ -134,7 +137,7 @@ export async function bulkDiscardChanges( { ...gitOptionsForWorktree(worktreePath, options), stdin: encodeGitPathspecs( - trackedPaths.map((filePath) => literalPathspec(filePath, options)) + trackedPaths.map((filePath) => literalPathspec(filePath, worktreePath, options)) ) } ) diff --git a/src/main/git/source-control/git-pathspec.test.ts b/src/main/git/source-control/git-pathspec.test.ts new file mode 100644 index 000000000000..131e0debaad1 --- /dev/null +++ b/src/main/git/source-control/git-pathspec.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { literalPathspec } from './git-pathspec' + +describe('literalPathspec', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posix-normalizes a Windows-style path when only the cwd says WSL', () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + expect( + literalPathspec('src\\index.ts', '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', {}) + ).toBe(':(literal)src/index.ts') + }) + + it('leaves a host path untouched', () => { + vi.stubGlobal('process', { ...process, platform: 'win32' }) + expect(literalPathspec('src\\index.ts', 'C:\\repo', {})).toBe(':(literal)src\\index.ts') + }) +}) diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index 7a31b8f33968..c128b94a7d46 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -2,7 +2,10 @@ import { commandLineLength, MAX_COMMAND_LINE_CHARS } from '../../../shared/windows-command-line-budget' -import { resolveGitCommandWithoutProbe } from '../command-runner/git-command-resolution' +import { + resolveGitCommandWithoutProbe, + wslDistroForCommand +} from '../command-runner/git-command-resolution' import { WINDOWS_CHECKOUT_GIT_CONFIG_ARGS } from '../wsl-windows-checkout-git-config' import type { GitRuntimeOptions } from '../git-runtime-options' @@ -17,9 +20,15 @@ const BULK_CHUNK_SIZE = 100 */ const POSIX_COMMAND_LINE_BUDGET = 128_000 -export function literalPathspec(filePath: string, options: GitRuntimeOptions): string { +export function literalPathspec( + filePath: string, + worktreePath: string, + options: GitRuntimeOptions +): string { // Why: Git inside WSL needs POSIX paths, but host paths must stay literal, so convert backslashes only for WSL. - const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + // The runner also routes a WSL-UNC cwd into WSL when options carry no distro, so check the cwd too. + const runsInWsl = options.wslDistro || wslDistroForCommand(worktreePath) + const runtimePath = runsInWsl ? filePath.replace(/\\/g, '/') : filePath return `:(literal)${runtimePath}` } @@ -76,7 +85,7 @@ export function bulkPathspecCommands( let pathspecs: string[] = [] let length = baseLength for (const filePath of filePaths) { - const pathspec = literalPathspec(filePath, options) + const pathspec = literalPathspec(filePath, worktreePath, options) const cost = finishedCommandLineLength([...leadingArgs, pathspec], worktreePath, options) - baseLength if (pathspecs.length > 0 && (pathspecs.length >= BULK_CHUNK_SIZE || length + cost > budget)) { diff --git a/src/main/git/source-control/staging.ts b/src/main/git/source-control/staging.ts index 371b46c69d2d..a14d09841733 100644 --- a/src/main/git/source-control/staging.ts +++ b/src/main/git/source-control/staging.ts @@ -25,7 +25,7 @@ export async function stageFile( invalidateGitReadCaches() try { await gitExecFileAsync( - ['add', '--', literalPathspec(filePath, options)], + ['add', '--', literalPathspec(filePath, worktreePath, options)], gitOptionsForWorktree(worktreePath, options) ) await redoWriteForDriveCheckoutSuspects(worktreePath, [filePath], STAGE_WRITE, options) @@ -45,9 +45,12 @@ export async function unstageFile( invalidateGitReadCaches() try { // Reset treats an unborn HEAD as an empty tree, preserving the working file. - await gitExecFileAsync(['reset', '--quiet', '--', literalPathspec(filePath, options)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['reset', '--quiet', '--', literalPathspec(filePath, worktreePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } finally { invalidateGitReadCaches() } @@ -68,7 +71,9 @@ export async function bulkStageFiles( try { await gitExecFileAsync(['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { ...gitOptionsForWorktree(worktreePath, options), - stdin: encodeGitPathspecs(filePaths.map((filePath) => literalPathspec(filePath, options))) + stdin: encodeGitPathspecs( + filePaths.map((filePath) => literalPathspec(filePath, worktreePath, options)) + ) }) await redoWriteForDriveCheckoutSuspects(worktreePath, filePaths, STAGE_WRITE, options) } finally { @@ -123,7 +128,9 @@ export async function bulkUnstageFiles( try { await gitExecFileAsync(['reset', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'], { ...gitOptionsForWorktree(worktreePath, options), - stdin: encodeGitPathspecs(filePaths.map((filePath) => literalPathspec(filePath, options))) + stdin: encodeGitPathspecs( + filePaths.map((filePath) => literalPathspec(filePath, worktreePath, options)) + ) }) } finally { invalidateGitReadCaches() diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index d2b9f1e629f8..9d8ccc4abd3e 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -6,6 +6,7 @@ import { parseGitHubOwnerRepo, parseGitHubOwnerRepoWithResolvedSshHostname, parseGitHubRemoteIdentity, + rawSshTransportHost, remoteUrlUsesSshTransport } from './github-remote-identity-parsing' @@ -188,3 +189,36 @@ describe('SSH Host alias identity (#10284)', () => { expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com') }) }) + +describe('SSH certificate org remotes (#30601)', () => { + const certRemote = 'org-123456@github.com:Acme/app.git' + + it('parses an org-id SCP remote as a GitHub identity', () => { + expect(parseGitHubRemoteIdentity(certRemote)).toEqual({ + host: 'github.com', + owner: 'Acme', + repo: 'app' + }) + expect(parseGitHubOwnerRepo(certRemote)).toEqual({ owner: 'Acme', repo: 'app' }) + }) + + it('treats an org-id SCP remote as SSH transport', () => { + expect(remoteUrlUsesSshTransport(certRemote)).toBe(true) + }) + + it('reads the transport host from an org-id SCP remote', () => { + expect(rawSshTransportHost(certRemote)).toBe('github.com') + }) + + it('does not need an SSH config expansion for an org-id github.com remote', () => { + expect(gitHubSshConfigHostAlias(certRemote)).toBeNull() + expect( + parseGitHubOwnerRepoWithResolvedSshHostname(certRemote, null) + ).toEqual({ owner: 'Acme', repo: 'app' }) + }) + + it('still ignores non-GitHub hosts carrying an org-id user', () => { + expect(parseGitHubOwnerRepo('org-123456@github-work:Acme/app.git')).toBeNull() + expect(gitHubSshConfigHostAlias('org-123456@github-work:Acme/app.git')).toBe('github-work') + }) +}) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index 32c640b8796e..c9ec59575f1b 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -27,14 +27,22 @@ function parseGitHubRemotePath(path: string): Pick@github.com:/`, + * so the SCP user is not always `git`. + */ +function isScpStyleRemote(remoteUrl: string): boolean { + return /^[^@\s/:]+@[^:\s/]+:[^\s]+$/.test(remoteUrl) +} + /** SSH transport host as written, preserving SCP alias case. */ export function rawSshTransportHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim() - const scpMatch = trimmed.match(/^git@([^:]+):/i) + const scpMatch = trimmed.match(/^[^@\s/:]+@([^:]+):/i) if (scpMatch) { return scpMatch[1] } @@ -63,7 +71,7 @@ export function gitHubSshConfigHostAlias(remoteUrl: string): string | null { export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null { const trimmed = remoteUrl.trim() - const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) + const sshMatch = trimmed.match(/^[^@\s/:]+@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) if (sshMatch) { return { host: normalizeGitHubRemoteHost(sshMatch[1]), owner: sshMatch[2], repo: sshMatch[3] } } diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts index dab984da73f9..5a40aa6744b7 100644 --- a/src/main/grok/grok-transcript-import.ts +++ b/src/main/grok/grok-transcript-import.ts @@ -11,6 +11,10 @@ export const GROK_TRANSCRIPT_IMPORT = directoryTranscriptImport({ resolveGrokHomeDir() ], findTranscript: async ({ providerSessionId, store, workspaceCwd, strictErrors }) => - (await findGrokChatHistoryByCwd({ sessionId: providerSessionId, cwd: workspaceCwd, sessionsDir: store })) ?? + (await findGrokChatHistoryByCwd({ + sessionId: providerSessionId, + cwd: workspaceCwd, + sessionsDir: store + })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: store, strictErrors }) }) diff --git a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts index f56ed30960a8..988a7e11d794 100644 --- a/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts +++ b/src/main/ipc/ai-vault-host-leg-cache-retention.test.ts @@ -60,12 +60,17 @@ function result(marker: string): AiVaultListResult { } } -function args(scan: () => Promise, force = false, depth = 4) { +function args( + scan: () => Promise, + force = false, + depth = 4, + scopePaths: readonly string[] = ['/workspaces/project'] +) { return { cacheKey: 'leg', depth, force, - scopePaths: ['/workspaces/project'], + scopePaths, scan } } @@ -185,16 +190,16 @@ describe('host session result cache lifetime', () => { expect(scan).toHaveBeenCalledTimes(4) }) - it('keeps the refreshed deadline and cached scoped depth when the old timer fires', async () => { + it('keeps the refreshed deadline and cached covering depth when the old timer fires', async () => { const scan = vi.fn().mockResolvedValue(result('old')) const owner = await cacheOwner(() => scanHostLegWithCache(args(scan))) await vi.advanceTimersByTimeAsync(4000) scan.mockResolvedValue(result('fresh')) await scanHostLegWithCache(args(scan, true)) await vi.advanceTimersByTimeAsync(11000) - const narrow = await scanHostLegWithCache(args(scan, false, 2)) + const narrow = await scanHostLegWithCache(args(scan, false, 2, [])) expect(narrow.sessions.map((session) => session.id)).toEqual( - [0, 1, 2].map((index) => `local:codex:fresh-${index}`) + [0, 1].map((index) => `local:codex:fresh-${index}`) ) expect(scan).toHaveBeenCalledTimes(2) expect(owner.size).toBe(1) @@ -254,6 +259,7 @@ describe('host session result cache lifetime', () => { expect(scan).toHaveBeenCalledTimes(1) }) + // Covering reuse applies to unscoped requests; a scoped request reuses only its own depth. it('keeps the first completed covering depth while returning every concurrent result', async () => { let finish: (value: AiVaultListResult) => void = () => { throw new Error('Scan not started') @@ -265,16 +271,17 @@ describe('host session result cache lifetime', () => { finish = resolve }), false, - 2 + 2, + [] ) ) - await scanHostLegWithCache(args(async () => result('first'))) + await scanHostLegWithCache(args(async () => result('first'), false, 4, [])) finish(result('later')) await expect(later).resolves.toEqual(result('later')) const unused = vi.fn().mockResolvedValue(result('unused')) - expect((await scanHostLegWithCache(args(unused, false, 2))).sessions[0].title).toBe('first') + expect((await scanHostLegWithCache(args(unused, false, 2, []))).sessions[0].title).toBe('first') expect(unused).not.toHaveBeenCalled() - await scanHostLegWithCache(args(unused, false, 5)) + await scanHostLegWithCache(args(unused, false, 5, [])) expect(unused).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts index 08ed9c81ed26..387e2072e56d 100644 --- a/src/main/ipc/ai-vault-host-leg-cache.ts +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -1,7 +1,7 @@ import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' import type { AiVaultListResult } from '../../shared/ai-vault-types' import { - aiVaultSessionDepthCovers, + aiVaultCachedDepthServes, truncateAiVaultListResult, type AiVaultSessionDepth } from '../../shared/ai-vault-session-depth' @@ -71,7 +71,7 @@ export async function scanHostLegWithCache(args: { !args.force && cached && cached.expiresAt > now && - aiVaultSessionDepthCovers(cached.depth, args.depth) && + aiVaultCachedDepthServes(cached.depth, args.depth, args.scopePaths) && (!cached.result.unchanged || (cached.depth === args.depth && cached.result.fingerprint === args.afterFingerprint)) ) { @@ -96,7 +96,7 @@ export async function scanHostLegWithCache(args: { !args.force && current && current.expiresAt > Date.now() && - aiVaultSessionDepthCovers(current.depth, args.depth) && + aiVaultCachedDepthServes(current.depth, args.depth, args.scopePaths) && (!current.result.unchanged || (current.depth === args.depth && current.result.fingerprint === args.afterFingerprint)) ) { diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 8463be64bba5..2116b0b5a0fc 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -22,7 +22,6 @@ import { isDashboardFilterOptions } from './dashboard-filter-payload-validation' export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 -const MAX_DASHBOARD_SUBAGENTS = 100 const MAX_DASHBOARD_REPO_ICONS = 500 // Why: sanitizing an image icon base64-decodes the whole data URI to read a // 24-byte header, and the renderer republishes the same icons every 250 ms. @@ -196,7 +195,6 @@ function isDashboardSubagents(value: unknown): boolean { } return ( Array.isArray(value) && - value.length <= MAX_DASHBOARD_SUBAGENTS && value.every((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return false diff --git a/src/main/ipc/desktop-browser-bridge-wiring.ts b/src/main/ipc/desktop-browser-bridge-wiring.ts index d7537233422c..e5c10f3a8c1e 100644 --- a/src/main/ipc/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/desktop-browser-bridge-wiring.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { webContents, type BrowserWindow } from 'electron' import { + DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY, DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, isDesktopBrowserBridgeMethod } from '../../shared/desktop-browser-bridge-protocol' @@ -135,7 +136,8 @@ export function installDesktopBrowserBridge(options: { { clientCapabilities: [ ...ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY + DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, + DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY ] } ) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index dfd1f4cea826..6fd1d2e64da1 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -20,6 +20,8 @@ import { localFileHandleMock } from './filesystem-test-harness' +const diskPreconditionMock = vi.hoisted(() => ({ writeLocalTextFileWithDiskPrecondition: vi.fn() })) +vi.mock('./filesystem/local-disk-write-precondition', () => diskPreconditionMock) vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) vi.mock('node:fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) @@ -309,6 +311,30 @@ describe('registerFilesystemHandlers', () => { expect(writeFileMock).not.toHaveBeenCalled() }) + it('routes a guarded save through the shared compare-and-write and returns its refusal', async () => { + lstatMock.mockResolvedValue({ isDirectory: () => false }) + diskPreconditionMock.writeLocalTextFileWithDiskPrecondition.mockResolvedValueOnce({ + ok: false, + diskConflict: 'changed' + }) + registerFilesystemHandlers(store as never) + const diskPrecondition = { expectedSignature: 'a-b-1' } + + await expect( + handlers.get('fs:writeFile')!(null, { + filePath: path.resolve('/workspace/repo/file.txt'), + content: 'data', + diskPrecondition + }) + ).resolves.toEqual({ ok: false, diskConflict: 'changed' }) + + expect(diskPreconditionMock.writeLocalTextFileWithDiskPrecondition).toHaveBeenCalledWith( + path.resolve('/workspace/repo/file.txt'), + 'data', + diskPrecondition + ) + }) + it.each([ ['fs:writeFile', { filePath: path.resolve('/workspace/repo/file.txt'), content: 'data' }], ['fs:deletePath', { targetPath: path.resolve('/workspace/repo/file.txt') }] diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 138b9b89b2d2..c99f6cd768e2 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain, shell } from 'electron' -import { lstat, writeFile } from 'node:fs/promises' +import { lstat } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/connection/ssh-connection-generation' import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' @@ -16,6 +16,11 @@ import type { FilesystemHandlerContext } from './filesystem-handler-context' import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' import { runNativeChatScratchSave } from '../../native-chat/native-chat-scratch-folder-lock' import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../../shared/file-write-disk-precondition' +import { writeLocalTextFileWithDiskPrecondition } from './local-disk-write-precondition' export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { const { store } = context @@ -29,8 +34,9 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex content: string connectionId?: string access?: LocalFileAccess + diskPrecondition?: FileWriteDiskPrecondition } & SshMutationExpectation - ): Promise => { + ): Promise => { assertSshMutationExpectation( args.connectionId, args.expectedSshTargetId, @@ -52,8 +58,9 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex } } await assertLocalWriteTargetIsRegularFile(filePath) - await runNativeChatScratchSave(getProfileUserDataPath(), filePath, () => - writeFile(filePath, args.content, 'utf-8') + const precondition = args.diskPrecondition + return runNativeChatScratchSave(getProfileUserDataPath(), filePath, () => + writeLocalTextFileWithDiskPrecondition(filePath, args.content, precondition) ) } ) diff --git a/src/main/ipc/filesystem/local-disk-write-precondition.test.ts b/src/main/ipc/filesystem/local-disk-write-precondition.test.ts new file mode 100644 index 000000000000..3e37ee6d8db6 --- /dev/null +++ b/src/main/ipc/filesystem/local-disk-write-precondition.test.ts @@ -0,0 +1,461 @@ +import { link, mkdir, mkdtemp, readFile, realpath, rm, symlink, 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 { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' +import type { RuntimeStore } from '../../runtime/runtime-store-contract' +import { createLocalFilesystemProvider } from '../../providers/local-filesystem-provider' +import { + checkLocalDiskWritePrecondition, + diskSignatureOfUtf8 +} from './local-disk-write-precondition' +import type * as LocalRegularFileRead from './local-regular-file-read' +import type * as NodeFsPromises from 'node:fs/promises' + +const fileRead = vi.hoisted(() => { + const state: { understateSizeBy: number; hangPath: string | null; openError: Error | null } = { + understateSizeBy: 0, + hangPath: null, + openError: null + } + return state +}) +const realpathFailure = vi.hoisted(() => { + const state: { path: string | null } = { path: null } + return state +}) +const zeroInodes = vi.hoisted(() => { + const state: { enabled: boolean } = { enabled: false } + return state +}) +const writeGates = vi.hoisted( + () => new Map void; release: Promise }>() +) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: some volumes (RAM disks, subst and network drives, WSL UNC) fail realpath outright. + realpath: async (path: string) => { + if (path === realpathFailure.path) { + throw Object.assign(new Error(`EIO: i/o error, realpath '${path}'`), { code: 'EIO' }) + } + return actual.realpath(path) + }, + // Why: 9P and some SMB mounts report inode 0 for every file. + stat: async (path: string, options?: { bigint?: boolean }) => { + if (options?.bigint !== true) { + return actual.stat(path) + } + const stats = await actual.stat(path, { bigint: true }) + return zeroInodes.enabled ? Object.assign(stats, { ino: 0n }) : stats + }, + // Why: holds one save between its passed check and its write, where a competing save must wait. + writeFile: async (...args: Parameters) => { + const gate = typeof args[1] === 'string' ? writeGates.get(args[1]) : undefined + if (gate) { + gate.entered() + await gate.release + } + return actual.writeFile(...args) + } + } +}) +vi.mock('./local-regular-file-read', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Why: reports the size from before a concurrent append, as a stat racing the writer would. + openLocalRegularFile: async (filePath: string) => { + if (fileRead.openError) { + throw fileRead.openError + } + if (filePath === fileRead.hangPath) { + // A read stuck on a dead network or WSL mount. + await new Promise(() => undefined) + } + const opened = await actual.openLocalRegularFile(filePath) + opened.stats.size -= fileRead.understateSizeBy + return opened + } + } +}) + +let base: string + +beforeEach(async () => { + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-disk-write-precondition-')) +}) + +afterEach(async () => { + fileRead.understateSizeBy = 0 + fileRead.hangPath = null + fileRead.openError = null + realpathFailure.path = null + writeGates.clear() + zeroInodes.enabled = false + await rm(base, { recursive: true, force: true }) +}) + +describe('checkLocalDiskWritePrecondition', () => { + it('passes when disk still holds the baseline content', async () => { + const filePath = join(base, 'a.ts') + const content = 'const cafรฉ = "โ˜• ๐„ž"\n' + await writeFile(filePath, content) + + await expect( + checkLocalDiskWritePrecondition(filePath, { + expectedSignature: getDiskBaselineSignature(content) + }) + ).resolves.toBeNull() + }) + + it('reports a same-size external rewrite as changed', async () => { + const filePath = join(base, 'a.ts') + await writeFile(filePath, 'agent') + + await expect( + checkLocalDiskWritePrecondition(filePath, { + expectedSignature: getDiskBaselineSignature('human') + }) + ).resolves.toBe('changed') + }) + + it('reports a size the baseline cannot decode to as changed', async () => { + const filePath = join(base, 'a.ts') + await writeFile(filePath, 'x'.repeat(100)) + + await expect( + checkLocalDiskWritePrecondition(filePath, { + expectedSignature: getDiskBaselineSignature('short') + }) + ).resolves.toBe('changed') + }) + + it('reports a file that grew past the baseline after its size check as changed', async () => { + const filePath = join(base, 'a.ts') + await writeFile(filePath, `ab${'x'.repeat(10)}`) + fileRead.understateSizeBy = 10 + + await expect( + checkLocalDiskWritePrecondition(filePath, { + expectedSignature: getDiskBaselineSignature('ab') + }) + ).resolves.toBe('changed') + }) + + it('reports a missing file unless the caller allows recreating it', async () => { + const filePath = join(base, 'gone.ts') + const expectedSignature = getDiskBaselineSignature('was here') + + await expect(checkLocalDiskWritePrecondition(filePath, { expectedSignature })).resolves.toBe( + 'missing' + ) + await expect( + checkLocalDiskWritePrecondition(filePath, { expectedSignature, allowMissing: true }) + ).resolves.toBeNull() + }) +}) + +describe('sliced disk hashing', () => { + it('matches the whole-file signature across slice boundaries and invalid bytes', async () => { + const slice = 1024 * 1024 + // A 4-byte character straddles the first slice edge, followed by bytes that are not UTF-8. + const text = Buffer.from(`${'a'.repeat(slice - 2)}๐„ž tail รฉ`, 'utf8') + const buffer = Buffer.concat([text, Buffer.from([0xff, 0xc3, 0x28, 0xe2, 0x82]), text]) + + await expect(diskSignatureOfUtf8(buffer)).resolves.toBe( + getDiskBaselineSignature(buffer.toString('utf8')) + ) + }) +}) + +describe('local provider guarded write', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write path reads nothing from the store; authorization is the injected stub. + const store = {} as RuntimeStore + const provider = () => + createLocalFilesystemProvider({ + requireStore: () => store, + resolveAuthorizedPath: vi.fn(async (path: string) => path) + }) + + it('writes when disk matches and refuses without writing when it does not', async () => { + const filePath = join(base, 'a.ts') + await writeFile(filePath, 'original') + + await expect( + provider().writeFileWithDiskPrecondition(filePath, 'edited', { + expectedSignature: getDiskBaselineSignature('original') + }) + ).resolves.toEqual({ ok: true }) + expect(await readFile(filePath, 'utf8')).toBe('edited') + + await expect( + provider().writeFileWithDiskPrecondition(filePath, 'stale', { + expectedSignature: getDiskBaselineSignature('original') + }) + ).resolves.toEqual({ ok: false, diskConflict: 'changed' }) + expect(await readFile(filePath, 'utf8')).toBe('edited') + }) + + it('lets only one of two concurrent saves from the same baseline write', async () => { + const filePath = join(base, 'a.ts') + await writeFile(filePath, 'original') + const fromBaseline = { expectedSignature: getDiskBaselineSignature('original') } + + const results = await Promise.all([ + provider().writeFileWithDiskPrecondition(filePath, 'client one', fromBaseline), + provider().writeFileWithDiskPrecondition(filePath, 'client two', fromBaseline) + ]) + + const winner = results.findIndex((result) => result.ok) + expect(results[1 - winner]).toEqual({ ok: false, diskConflict: 'changed' }) + expect(await readFile(filePath, 'utf8')).toBe(winner === 0 ? 'client one' : 'client two') + }) + + // Why: creating symlinks needs elevated rights on Windows runners. + it.skipIf(process.platform === 'win32').each([ + ['a symlink', (target: string, alias: string) => symlink(target, alias)], + ['a hardlink', (target: string, alias: string) => link(target, alias)] + ])( + 'lets only one save win when two clients reach one file through %s', + async (_label, makeAlias) => { + const target = join(base, 'target.ts') + const alias = join(base, 'alias.ts') + await writeFile(target, 'original') + await makeAlias(target, alias) + const fromBaseline = { expectedSignature: getDiskBaselineSignature('original') } + + const results = await Promise.all([ + provider().writeFileWithDiskPrecondition(target, 'through target', fromBaseline), + provider().writeFileWithDiskPrecondition(alias, 'through alias', fromBaseline) + ]) + + expect(results.filter((result) => result.ok)).toHaveLength(1) + } + ) + + it('does not chain unrelated files on a volume that reports inode 0', async () => { + const stuck = join(base, 'stuck.ts') + const other = join(base, 'other.ts') + await writeFile(stuck, 'original') + await writeFile(other, 'original') + zeroInodes.enabled = true + fileRead.hangPath = stuck + const fromBaseline = { expectedSignature: getDiskBaselineSignature('original') } + + void provider().writeFileWithDiskPrecondition(stuck, 'edited', fromBaseline) + const outcome = await Promise.race([ + provider().writeFileWithDiskPrecondition(other, 'edited', fromBaseline), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 1_000)) + ]) + + expect(outcome).toEqual({ ok: true }) + }) + + it('keeps saves ordered when a queued restore creates the file mid-queue', async () => { + const filePath = join(base, 'restored.ts') + const fromRestored = { expectedSignature: getDiskBaselineSignature('restored') } + const gateWrite = (content: string) => { + let entered: () => void = () => undefined + let release: () => void = () => undefined + const reached = new Promise((resolve) => { + entered = resolve + }) + writeGates.set(content, { + entered, + release: new Promise((resolve) => { + release = resolve + }) + }) + return { reached, release: () => release() } + } + const restoreGate = gateWrite('restored') + const saveBGate = gateWrite('save B') + + // The restore holds the lock while the file is still missing, so B queues on its path. + const restore = provider().writeFileWithDiskPrecondition(filePath, 'restored', { + ...fromRestored, + allowMissing: true + }) + await restoreGate.reached + const saveB = provider().writeFileWithDiskPrecondition(filePath, 'save B', fromRestored) + await new Promise((resolve) => setTimeout(resolve, 20)) + restoreGate.release() + await saveBGate.reached + // C arrives once the file exists, so an inode-only key would let it race B. + const saveC = provider().writeFileWithDiskPrecondition(filePath, 'save C', fromRestored) + await new Promise((resolve) => setTimeout(resolve, 50)) + saveBGate.release() + + const results = await Promise.all([restore, saveB, saveC]) + expect(results.slice(0, 2)).toEqual([{ ok: true }, { ok: true }]) + expect(results[2]).toEqual({ ok: false, diskConflict: 'changed' }) + expect(await readFile(filePath, 'utf8')).toBe('save B') + }) + + it('still saves where the folder cannot be resolved to a real path', async () => { + const filePath = join(base, 'new.ts') + realpathFailure.path = base + + await expect( + provider().writeFileWithDiskPrecondition(filePath, 'created', { + expectedSignature: getDiskBaselineSignature('anything'), + allowMissing: true + }) + ).resolves.toEqual({ ok: true }) + expect(await readFile(filePath, 'utf8')).toBe('created') + }) + + it.skipIf(process.platform === 'win32')( + 'lets only one save create a missing file reached through a dangling symlink', + async () => { + const target = join(base, 'target.ts') + const link = join(base, 'link.ts') + await symlink(target, link) + const createIfMissing = { + expectedSignature: getDiskBaselineSignature('original'), + allowMissing: true + } + + const results = await Promise.all([ + provider().writeFileWithDiskPrecondition(target, 'through target', createIfMissing), + provider().writeFileWithDiskPrecondition(link, 'through link', createIfMissing) + ]) + + expect(results.filter((result) => result.ok)).toHaveLength(1) + } + ) + + it.skipIf(process.platform === 'win32')( + 'lets only one save create a missing file at the end of a 20-link dangling chain', + async () => { + const target = join(base, 'target.ts') + let previous = target + for (let index = 0; index < 20; index += 1) { + const link = join(base, `link-${index}.ts`) + await symlink(previous, link) + previous = link + } + const createIfMissing = { + expectedSignature: getDiskBaselineSignature('original'), + allowMissing: true + } + + const results = await Promise.all([ + provider().writeFileWithDiskPrecondition(target, 'through target', createIfMissing), + provider().writeFileWithDiskPrecondition(previous, 'through chain', createIfMissing) + ]) + + expect(results.filter((result) => result.ok)).toHaveLength(1) + } + ) + + it.skipIf(process.platform === 'win32')( + 'refuses a guarded save through a symlink loop instead of failing', + async () => { + const a = join(base, 'a.ts') + const b = join(base, 'b.ts') + await symlink(b, a) + await symlink(a, b) + + await expect( + provider().writeFileWithDiskPrecondition(a, 'edited', { + expectedSignature: getDiskBaselineSignature('original'), + allowMissing: true + }) + ).resolves.toEqual({ ok: false, diskConflict: 'changed' }) + } + ) + + it.skipIf(process.platform === 'win32')( + 'resolves a relative dangling link from its real folder, not the symlinked path to it', + async () => { + // base/alias -> base/real/sub, and sub/link.ts -> ../target.ts, so the target is base/real/target.ts. + await mkdir(join(base, 'real', 'sub'), { recursive: true }) + await symlink(join(base, 'real', 'sub'), join(base, 'alias')) + await symlink('../target.ts', join(base, 'real', 'sub', 'link.ts')) + const createIfMissing = { + expectedSignature: getDiskBaselineSignature('original'), + allowMissing: true + } + + const results = await Promise.all([ + provider().writeFileWithDiskPrecondition( + join(base, 'alias', 'link.ts'), + 'through alias', + createIfMissing + ), + provider().writeFileWithDiskPrecondition( + join(base, 'real', 'target.ts'), + 'through target', + createIfMissing + ) + ]) + + expect(results.filter((result) => result.ok)).toHaveLength(1) + } + ) + + it('does not hold up a save to another file in the folder behind a stuck read', async () => { + const stuck = join(base, 'stuck.ts') + const other = join(base, 'other.ts') + await writeFile(stuck, 'original') + await writeFile(other, 'original') + fileRead.hangPath = stuck + const fromBaseline = { expectedSignature: getDiskBaselineSignature('original') } + + void provider().writeFileWithDiskPrecondition(stuck, 'edited', fromBaseline) + const otherSave = provider().writeFileWithDiskPrecondition(other, 'edited', fromBaseline) + const outcome = await Promise.race([ + otherSave, + new Promise((resolve) => setTimeout(() => resolve('blocked'), 1_000)) + ]) + + expect(outcome).toEqual({ ok: true }) + }) + + it('writes a save with no precondition directly, not queued behind a stuck guarded save', async () => { + const filePath = join(base, 'stuck.ts') + await writeFile(filePath, 'original') + fileRead.hangPath = filePath + + void provider().writeFileWithDiskPrecondition(filePath, 'guarded', { + expectedSignature: getDiskBaselineSignature('original') + }) + const plainSave = provider().writeFileWithDiskPrecondition(filePath, 'plain', undefined) + const outcome = await Promise.race([ + plainSave, + new Promise((resolve) => setTimeout(() => resolve('blocked'), 1_000)) + ]) + + expect(outcome).toEqual({ ok: true }) + expect(await readFile(filePath, 'utf-8')).toBe('plain') + }) + + it('does not read a permission error whose path names ENOENT as a missing file', async () => { + const filePath = join(base, 'locked.ts') + fileRead.openError = Object.assign( + new Error(`EACCES: permission denied, open '${base}/ENOENT: no such file or directory.ts'`), + { code: 'EACCES' } + ) + + await expect( + checkLocalDiskWritePrecondition(filePath, { + expectedSignature: getDiskBaselineSignature('original'), + allowMissing: true + }) + ).rejects.toThrow('EACCES') + }) + + it('does not resurrect a deleted file without allowMissing', async () => { + const filePath = join(base, 'gone.ts') + + await expect( + provider().writeFileWithDiskPrecondition(filePath, 'edited', { + expectedSignature: getDiskBaselineSignature('original') + }) + ).resolves.toEqual({ ok: false, diskConflict: 'missing' }) + await expect(readFile(filePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/main/ipc/filesystem/local-disk-write-precondition.ts b/src/main/ipc/filesystem/local-disk-write-precondition.ts new file mode 100644 index 000000000000..f46303fbb562 --- /dev/null +++ b/src/main/ipc/filesystem/local-disk-write-precondition.ts @@ -0,0 +1,172 @@ +import { StringDecoder } from 'node:string_decoder' +import { + DiskBaselineSignatureBuilder, + getDiskBaselineSignature, + diskBaselineSignatureLength, + utf8ByteSizeCanDecodeToLength +} from '../../../shared/disk-baseline-signature' +import { + compareDiskWritePrecondition, + FILE_WRITE_OK, + type FileWriteDiskConflict, + type FileWriteDiskPrecondition, + type FileWriteResult +} from '../../../shared/file-write-disk-precondition' +import { readlink, realpath, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, join, normalize, resolve } from 'node:path' +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import { serializedParentDirectoryKey } from '../../destination-serialized-local-rename' +import { + FileTooLargeError, + openLocalRegularFile, + readLocalFileBounded +} from './local-regular-file-read' + +const HASH_SLICE_BYTES = 1024 * 1024 + +/** Why sliced: hashing tens of MB in one go stalls the main process; yielding keeps windows responsive. */ +export async function diskSignatureOfUtf8(buffer: Buffer): Promise { + if (buffer.length <= HASH_SLICE_BYTES) { + return getDiskBaselineSignature(buffer.toString('utf8')) + } + const decoder = new StringDecoder('utf8') + const signature = new DiskBaselineSignatureBuilder() + for (let offset = 0; offset < buffer.length; offset += HASH_SLICE_BYTES) { + signature.update(decoder.write(buffer.subarray(offset, offset + HASH_SLICE_BYTES))) + await new Promise((resolve) => setImmediate(resolve)) + } + return signature.update(decoder.end()).digest() +} + +/** Reads the file the writer is about to replace and reports why it no longer matches the baseline. */ +export async function checkLocalDiskWritePrecondition( + filePath: string, + precondition: FileWriteDiskPrecondition +): Promise { + let opened: Awaited> + try { + opened = await openLocalRegularFile(filePath) + } catch (error) { + // Why the code alone: the message quotes the path, which can spell any error. + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return compareDiskWritePrecondition(precondition, null) + } + throw error + } + const { handle, stats } = opened + try { + const expectedLength = diskBaselineSignatureLength(precondition.expectedSignature) + // Why: the signature pins the decoded length, so a size it can't decode to skips reading a huge file. + if (expectedLength === null || !utf8ByteSizeCanDecodeToLength(stats.size, expectedLength)) { + return 'changed' + } + let buffer: Buffer + try { + buffer = await readLocalFileBounded(handle, expectedLength * 3, stats.size) + } catch (error) { + // Why: grown past what the baseline could decode from since the size check, so it changed. + if (error instanceof FileTooLargeError) { + return 'changed' + } + throw error + } + return (await diskSignatureOfUtf8(buffer)) === precondition.expectedSignature ? null : 'changed' + } finally { + await handle.close() + } +} + +const editorWritesByPath = new Map>() +const editorWritesByInode = new Map>() + +// Why 40: Linux's own limit, so any chain the OS can open resolves here too. +const MAX_SYMLINK_HOPS = 40 + +/** + * Follows a symlink chain by hand, since realpath fails once its final target is missing. Null + * for a chain that never ends (a loop), whose writes could not share one lock. + */ +async function followSymlinks(filePath: string): Promise { + let current = filePath + for (let hop = 0; hop <= MAX_SYMLINK_HOPS; hop += 1) { + let target: string + try { + target = await readlink(current) + } catch { + return current + } + // Why the real folder: a relative `..` resolves against where the link really lives. + const linkFolder = await realpath(dirname(current)).catch(() => dirname(current)) + current = resolve(linkFolder, target) + } + return null +} + +/** + * A file's canonical path: real parent plus a case- and Unicode-folded name (folding only + * over-serializes). Symlinks are followed first, so a dangling link and its future target share it. + */ +async function editorWritePathKey(requestedPath: string): Promise { + const filePath = await followSymlinks(requestedPath) + if (filePath === null) { + return null + } + let parentKey: string + try { + parentKey = await serializedParentDirectoryKey(filePath) + } catch { + // Why: the key only orders saves; a volume that can't resolve real paths must still save. + parentKey = normalize(dirname(filePath)) + } + return join(parentKey, basename(filePath).normalize('NFC').toLowerCase()) +} + +/** The file's inode (stat follows symlinks), or null when missing or unknown. */ +async function editorWriteInodeKey(filePath: string): Promise { + try { + const stats = await stat(filePath, { bigint: true }) + // Why: 9P and some SMB mounts report inode 0 for every file. + return stats.ino === 0n ? null : `${stats.dev}:${stats.ino}` + } catch { + return null + } +} + +/** + * Compareโ†’write for editor saves, serialized per file across this process's IPC and runtime + * writers so two clients holding one baseline can't both pass. Two locks, always in this order: + * the canonical path (stable even while a queued save creates the file) and then, if it exists, + * the inode (shared by symlink and hardlink aliases). Per file, so a read stuck on a dead mount + * holds up only that file. A write by another process between the compare and the write is not + * prevented; the window is one read long. + */ +export async function writeLocalTextFileWithDiskPrecondition( + filePath: string, + content: string, + precondition: FileWriteDiskPrecondition | undefined +): Promise { + // Why: an unguarded write has nothing to compare, so it skips the locks a stuck guarded save holds. + if (!precondition) { + await writeFile(filePath, content, 'utf-8') + return FILE_WRITE_OK + } + const compareAndWrite = async (): Promise => { + const diskConflict = await checkLocalDiskWritePrecondition(filePath, precondition) + if (diskConflict) { + return { ok: false, diskConflict } + } + await writeFile(filePath, content, 'utf-8') + return FILE_WRITE_OK + } + const pathKey = await editorWritePathKey(filePath) + if (pathKey === null) { + // Why refuse: a link loop can't be compared or locked, and disk can't hold the baseline there. + return { ok: false, diskConflict: 'changed' } + } + return runKeyedSerializedOperation(editorWritesByPath, pathKey, async () => { + const inodeKey = await editorWriteInodeKey(filePath) + return inodeKey === null + ? compareAndWrite() + : runKeyedSerializedOperation(editorWritesByInode, inodeKey, compareAndWrite) + }) +} diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts index acffa8cb1098..b861c32fadc0 100644 --- a/src/main/ipc/filesystem/local-regular-file-read.ts +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -10,8 +10,10 @@ export const LOCAL_READ_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK const LOCAL_WRITE_PROBE_FLAGS = constants.O_WRONLY | (constants.O_NONBLOCK ?? 0) const READ_CHUNK_BYTES = 1024 * 1024 +export class FileTooLargeError extends Error {} + export function fileTooLargeError(size: number, limit: number): Error { - return new Error( + return new FileTooLargeError( `File too large: ${(size / 1024 / 1024).toFixed(1)}MB exceeds ${limit / 1024 / 1024}MB limit` ) } diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts index bf3cb64e2584..c0fc97c5c9b8 100644 --- a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -60,7 +60,7 @@ function begin(repos = [folder()], meta: ReturnType getRepos: () => repos, getRepo: (id: string) => repos.find((repo) => repo.id === id), getAllWorktreeMeta: () => meta, - updateRepo: vi.fn((id: string, patch: Partial) => { + updateRepo: vi.fn(({ id }: Pick, patch: Partial) => { const repo = repos.find((row) => row.id === id) return repo ? Object.assign(repo, patch) : null }) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index ebc58b92b721..7f8e8607d67a 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -82,6 +82,7 @@ import { stopFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade' import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake' +import { repoTarget } from '../persistence/repo-host-target-matcher.test-fixture' type TestWindow = { destroyed: boolean @@ -128,7 +129,7 @@ function makeStore( getAllWorktreeMeta: () => worktreeMeta, getRepos: () => repos, getRepo: vi.fn((id: string) => repos.find((repo) => repo.id === id)), - updateRepo: vi.fn((id: string, updates: Partial) => { + updateRepo: vi.fn(({ id }: Pick, updates: Partial) => { const repo = repos.find((candidate) => candidate.id === id) if (!repo) { return null @@ -199,7 +200,7 @@ describe('folder repo git upgrade watch', () => { gitInit(repoPath) await tick() - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('folder-repo'), { kind: 'git', folderUpgradeGitRootPath: repoPath.replaceAll('\\', '/'), externalWorktreeVisibility: 'hide' @@ -226,7 +227,7 @@ describe('folder repo git upgrade watch', () => { // A fixed tick wait can finish before the marker stat resolves under load. await vi.waitFor( () => { - expect(store.updateRepo).toHaveBeenCalledWith('folder-repo', { + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('folder-repo'), { kind: 'git', folderUpgradeGitRootPath: join(root, 'symlinked-project').replaceAll('\\', '/') }) @@ -257,7 +258,7 @@ describe('folder repo git upgrade watch', () => { await tick(2) expect(store.updateRepo).toHaveBeenCalledWith( - 'folder-repo', + repoTarget('folder-repo'), expect.objectContaining({ kind: 'git' }) ) }) @@ -316,7 +317,7 @@ describe('folder repo git upgrade watch', () => { await tick(2) expect(store.updateRepo).toHaveBeenCalledWith( - 'folder-repo', + repoTarget('folder-repo'), expect.objectContaining({ kind: 'git' }) ) }) @@ -349,7 +350,7 @@ describe('folder repo git upgrade watch', () => { await tick(2) expect(store.updateRepo).toHaveBeenCalledWith( - 'folder-repo', + repoTarget('folder-repo'), expect.objectContaining({ kind: 'git' }) ) expect(notifyReposChanged).toHaveBeenCalledWith(nextWindow) @@ -374,7 +375,7 @@ describe('folder repo git upgrade watch', () => { expect(store.updateRepo).toHaveBeenCalledTimes(1) expect(store.updateRepo).toHaveBeenCalledWith( - 'repo-a', + repoTarget('repo-a'), expect.objectContaining({ kind: 'git' }) ) }) @@ -434,7 +435,7 @@ describe('folder repo git upgrade watch', () => { await tick(2) expect(store.updateRepo).toHaveBeenCalledWith( - 'late-repo', + repoTarget('late-repo'), expect.objectContaining({ kind: 'git' }) ) }) @@ -463,7 +464,7 @@ describe('folder repo git upgrade watch', () => { await tick() expect(store.updateRepo).toHaveBeenCalledWith( - 'folder-repo', + repoTarget('folder-repo'), expect.objectContaining({ kind: 'git' }) diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index a91bb5afdc3d..68cdb2135697 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -117,7 +117,7 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise ({ vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock })) describe('parcel watcher process canary', () => { - let originalMessageListeners: ReturnType - let originalExitListeners: ReturnType - let originalDisconnectListeners: ReturnType + let originalMessageListeners: NodeJS.MessageListener[] + let originalExitListeners: NodeJS.ExitListener[] + let originalDisconnectListeners: NodeJS.DisconnectListener[] let originalSend: typeof process.send beforeEach(() => { @@ -77,12 +77,16 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 7, - dir: '/repo/.git', - opts: { mode: 'shallow', include: ['HEAD'] } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 7, + dir: '/repo/.git', + opts: { mode: 'shallow', include: ['HEAD'] } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith( @@ -119,12 +123,16 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 1, - dir: '/repo/.git', - opts: { mode: 'shallow', include: ['HEAD'] } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 1, + dir: '/repo/.git', + opts: { mode: 'shallow', include: ['HEAD'] } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) expect(subscribeMock).toHaveBeenCalledTimes(1) @@ -148,7 +156,7 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/large-repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/large-repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(30_000) @@ -165,12 +173,12 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(10_000) expect(writeFileSyncMock).toHaveBeenCalledTimes(1) - process.emit('message', { op: 'subscribe', id: 2, dir: '/large-repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/large-repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(20_000) expect(writeFileSyncMock).toHaveBeenCalledTimes(1) @@ -186,12 +194,12 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 1 }) + process.emit('message', { op: 'unsubscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(30_000) expect(writeFileSyncMock).toHaveBeenCalledTimes(3) @@ -215,11 +223,11 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 1 }) + process.emit('message', { op: 'unsubscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(30_000) expect(deadlockedUnsubscribe).not.toHaveBeenCalled() @@ -244,15 +252,15 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 1 }) + process.emit('message', { op: 'unsubscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(0) expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) - process.emit('message', { op: 'subscribe', id: 3, dir: '/repo-c', opts: {} }) + process.emit('message', { op: 'subscribe', id: 3, dir: '/repo-c', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(30_000) expect(subscribeMock).toHaveBeenCalledTimes(3) @@ -275,12 +283,12 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/live-repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/live-repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 2, dir: '/crawling-repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/crawling-repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 2 }) - process.emit('message', { op: 'subscribe', id: 3, dir: '/later-repo', opts: {} }) + process.emit('message', { op: 'unsubscribe', id: 2 }, undefined) + process.emit('message', { op: 'subscribe', id: 3, dir: '/later-repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(30_000) expect(writeFileSyncMock).not.toHaveBeenCalled() @@ -308,10 +316,10 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} }) - process.emit('message', { op: 'cancel-subscribe', id: 2 }) + process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} }, undefined) + process.emit('message', { op: 'cancel-subscribe', id: 2 }, undefined) // Why: non-restart cancel reuses async unsubscribe teardown; drain the // exclusive lifecycle queue after the active crawl finishes. @@ -337,11 +345,11 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) - process.emit('message', { op: 'cancel-subscribe', id: 1 }) + process.emit('message', { op: 'cancel-subscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(0) expect(unsubscribe).toHaveBeenCalledTimes(1) @@ -362,9 +370,9 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 1 }) + process.emit('message', { op: 'unsubscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith( @@ -390,9 +398,9 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'cancel-subscribe', id: 1 }) + process.emit('message', { op: 'cancel-subscribe', id: 1 }, undefined) expect(sendMock).toHaveBeenCalledWith( { op: 'cancel-requires-restart', id: 1 }, @@ -409,7 +417,7 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(30_000) @@ -436,13 +444,17 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 1, - dir: '/repo', - opts: {}, - delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) callback?.(null, [ @@ -488,7 +500,7 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) callback?.( @@ -532,7 +544,7 @@ describe('parcel watcher process canary', () => { process.send = sendMock await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'subscribe', id: 1, dir, opts: {} }) + process.emit('message', { op: 'subscribe', id: 1, dir, opts: {} }, undefined) await vi.advanceTimersByTimeAsync(0) sendMock.mockClear() @@ -575,13 +587,17 @@ describe('parcel watcher process canary', () => { process.send = sendMock await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 1, - dir: '/repo', - opts: {}, - delivery: { maxEventsPerBatch: 1 } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { maxEventsPerBatch: 1 } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) sendMock.mockClear() @@ -628,13 +644,17 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 1, - dir: '/repo', - opts: {}, - delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) @@ -673,19 +693,23 @@ describe('parcel watcher process canary', () => { await import('./parcel-watcher-process-entry') await vi.advanceTimersByTimeAsync(0) - process.emit('message', { - op: 'subscribe', - id: 1, - dir: '/repo', - opts: {}, - delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } - }) + process.emit( + 'message', + { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 } + }, + undefined + ) await vi.advanceTimersByTimeAsync(0) callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) callback?.(null, [{ type: 'update', path: '/repo/queued.txt' }]) await vi.advanceTimersByTimeAsync(0) - process.emit('message', { op: 'unsubscribe', id: 1 }) + process.emit('message', { op: 'unsubscribe', id: 1 }, undefined) await vi.advanceTimersByTimeAsync(0) releaseFirstEvent?.() await vi.advanceTimersByTimeAsync(0) diff --git a/src/main/ipc/pty-closed-pane-spawn-refusal.test.ts b/src/main/ipc/pty-closed-pane-spawn-refusal.test.ts new file mode 100644 index 000000000000..75965d120bba --- /dev/null +++ b/src/main/ipc/pty-closed-pane-spawn-refusal.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + _resetClosedPaneFencesForTests, + fencedIfLayoutOwner, + partitionsFence +} from '../persistence/terminal-topology/closed-pane-binding-fence' +import { Store } from '../persistence' +import { spawnMock } from './pty-ipc-mock-registry' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) +vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const TAB = 'tab-1' +const KEPT = '11111111-1111-4111-8111-111111111111' +const TAKEN = '22222222-2222-4222-8222-222222222222' + +function sessionWith(leafIds: string[]): WorkspaceSessionState { + const [first, second] = leafIds + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + [TAB]: { + root: second + ? { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first! }, + second: { type: 'leaf', leafId: second } + } + : { type: 'leaf', leafId: first! }, + activeLeafId: first!, + expandedLeafId: null + } + } + } +} + +describe('a window spawn for a pane main took back while it waited', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + afterEach(() => _resetClosedPaneFencesForTests()) + + it('is refused before the provider spawns anything', async () => { + const current = { value: sessionWith([KEPT, TAKEN]) } + fencedIfLayoutOwner( + () => partitionsFence(['local'], () => current.value, true), + () => ((current.value = sessionWith([KEPT])), { value: null }) + )() + const store: Store = Object.create(Store.prototype) + vi.spyOn(store, 'getWorkspaceSessionHostIds').mockReturnValue([LOCAL_EXECUTION_HOST_ID]) + vi.spyOn(store, 'getWorkspaceSession').mockImplementation(() => current.value) + handlers.clear() + registerPtyHandlers(mainWindow, undefined, undefined, undefined, undefined, store) + spawnMock.mockClear() + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + command: 'touch ran', + tabId: TAB, + leafId: TAKEN + }) + ).rejects.toThrow('terminal_pane_closed') + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index dd2c4b5bf345..0389858697ba 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -4,6 +4,7 @@ import { registerPtyHandlers } from './pty/register-handlers' import { setPtyOwnership } from './pty/provider/ownership-state' import { setLocalPtyProvider } from './pty/provider/registry' import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -304,7 +305,11 @@ describe('registerPtyHandlers', () => { expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(false) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'relay-pty', + expect.objectContaining({ unverifiableReason: SSH_PROVIDER_UNREGISTERED_REASON }) + ) expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1, undefined) }) it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => { @@ -332,7 +337,7 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( 'ssh-1', 'remote-pty', - 'terminated' + expect.objectContaining({ unverifiableReason: SSH_PROVIDER_UNREGISTERED_REASON }) ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index 763c9e2f863d..65dea10d0e5e 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-h import { deletePtyOwnership, setPtyOwnership } from './pty/provider/ownership-state' import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -233,7 +234,11 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' }) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + 'ssh-1', + 'relay-pty', + expect.objectContaining({ unverifiableReason: SSH_PROVIDER_UNREGISTERED_REASON }) + ) }) it('ignores fire-and-forget IPC for SSH-owned PTYs this process cannot dispatch', () => { const local = { write: vi.fn(), resize: vi.fn(), sendSignal: vi.fn() } diff --git a/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.test.ts b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.test.ts new file mode 100644 index 000000000000..ce4800411536 --- /dev/null +++ b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { Store } from '../../../persistence' +import { + _resetClosedPaneFencesForTests, + fencedIfLayoutOwner, + partitionsFence +} from '../../../persistence/terminal-topology/closed-pane-binding-fence' +import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +function session(leafIds: string[]): WorkspaceSessionState { + const [first, second] = leafIds + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + t: { + root: second + ? { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first! }, + second: { type: 'leaf', leafId: second } + } + : { type: 'leaf', leafId: first! }, + activeLeafId: first!, + expandedLeafId: null + } + } + } +} + +function storeHolding(current: { value: WorkspaceSessionState }): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: refuseClosedPaneSpawn reads only these two Store members. + return { + getWorkspaceSessionHostIds: () => ['local'], + getWorkspaceSession: () => current.value + } as unknown as Store +} + +describe('a spawn for a pane main closed while it waited', () => { + afterEach(() => _resetClosedPaneFencesForTests()) + + it('starts nothing once main took the pane back', () => { + const current = { value: session(['a', 'b']) } + fencedIfLayoutOwner( + () => partitionsFence(['local'], () => current.value, true), + () => ((current.value = session(['a'])), { value: null }) + )() + + expect(() => refuseClosedPaneSpawn(storeHolding(current), 't', 'b')).toThrow( + 'terminal_pane_closed' + ) + }) + + it('starts a pane main never closed, or one it holds again', () => { + const current = { value: session(['a', 'b']) } + expect(() => refuseClosedPaneSpawn(storeHolding(current), 't', 'b')).not.toThrow() + + fencedIfLayoutOwner( + () => partitionsFence(['local'], () => current.value, true), + () => ((current.value = session(['a'])), { value: null }) + )() + current.value = session(['a', 'b']) + expect(() => refuseClosedPaneSpawn(storeHolding(current), 't', 'b')).not.toThrow() + }) +}) diff --git a/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts new file mode 100644 index 000000000000..ab5940405529 --- /dev/null +++ b/src/main/ipc/pty/ipc/closed-pane-spawn-refusal.ts @@ -0,0 +1,28 @@ +import { isClosedPaneFenced } from '../../../persistence/terminal-topology/closed-pane-binding-fence' +import type { Store } from '../../../persistence' +import { layoutContainsLeafId } from '../../../../shared/workspace-layout/terminal-pane-tree' + +/** + * Layout-owner build: a spawn for a pane main closed or took back, which main's layout no longer + * holds, starts nothing. Its binding would be refused anyway (pty-binding-refusals.ts); refusing + * first means its startup command never runs. + */ +export function refuseClosedPaneSpawn( + store: Store | undefined, + tabId: string | undefined, + leafId: string | null | undefined +): void { + // The fence's own key: legacy non-UUID leaf ids are matched, never thrown on. + if (!tabId || !leafId || !isClosedPaneFenced(`${tabId}:${leafId}`)) { + return + } + const held = (store?.getWorkspaceSessionHostIds() ?? []).some((hostId) => + layoutContainsLeafId( + store?.getWorkspaceSession(hostId)?.terminalLayoutsByTabId?.[tabId]?.root ?? null, + leafId + ) + ) + if (!held) { + throw new Error('terminal_pane_closed') + } +} diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index c8ead4d85621..1995ff366210 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -14,6 +14,8 @@ import { type ResolvedPtyHost } from '../provider/registry' import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' +import { publishSyntheticExit, stopLegacyRelayBridgedPty } from '../runtime/kill' +import { PtyExitProof, PtyRetirementWithoutProof } from '../../../pty-exit-proof' export type PtyKillIpcDeps = { store?: Store @@ -80,16 +82,14 @@ async function stopRendererOwnedPtyProcess( deps: PtyKillIpcDeps, args: { id: string; keepHistory?: boolean } ): Promise { - const { - store, - runtime, - getLocalPtyProviderStartupPromise, - shutdownProviderAndDetectExit, - rememberSyntheticKillExit, - sendPtyExitToRenderer - } = deps + const { store, runtime, getLocalPtyProviderStartupPromise, shutdownProviderAndDetectExit } = deps runtime?.markPtyStopRequested?.(args.id) const connectionId = getPtySshConnectionId(args.id) + const bridgedStop = stopLegacyRelayBridgedPty(args.id, connectionId, args.keepHistory === true) + if (bridgedStop) { + await bridgedStop + return + } // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). const startupPromise = getLocalPtyProviderStartupPromise(resolvePtyExecutionHost(args.id)) if (startupPromise) { @@ -100,41 +100,30 @@ async function stopRendererOwnedPtyProcess( // Why: detached SSH PTYs intentionally keep ownership after their // provider is unregistered; hydrated app-scoped ids can also arrive // before ownership is rebuilt. Tombstone instead of falling back local. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id, incarnationId) - sendPtyExitToRenderer({ - id: args.id, - code: -1, - ...(incarnationId ? { incarnationId } : {}) - }) + const unproven = PtyRetirementWithoutProof.because(SSH_PROVIDER_UNREGISTERED_REASON) + const incarnationId = finishPtyShutdown(args.id, connectionId, store, unproven) + publishSyntheticExit(deps, args.id, -1, incarnationId, unproven) return } const shutdownProvider = provider ?? getProviderForPty(args.id) - let providerExitObserved = false + let proof: PtyExitProof try { - providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { + const providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { immediate: true, keepHistory: args.keepHistory ?? false }) + proof = PtyExitProof.from(providerExitObserved ? 'exit-observed' : 'shutdown-acknowledged') } catch (err) { if (!isPtyAlreadyGoneError(err)) { // Why: a failed shutdown can leave the local daemon's process alive; keep ownership so a retry can reach it. throw err } - /* session already dead โ€” cleanup below handles the rest */ + proof = PtyExitProof.from('absent') } // Why: some shutdown paths do not emit onExit through the provider listener. // Explicit cleanup is idempotent and covers already-dead PTYs. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id, incarnationId) - sendPtyExitToRenderer({ - id: args.id, - code: -1, - ...(incarnationId ? { incarnationId } : {}) - }) + const incarnationId = finishPtyShutdown(args.id, connectionId, store, proof) + if (proof.source !== 'exit-observed') { + publishSyntheticExit(deps, args.id, -1, incarnationId, proof) } } diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 56075d02fa60..fd25968b02dc 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -17,6 +17,8 @@ import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { if (!ctx.agentTeamsLeaderHandle) { @@ -76,6 +78,10 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } // Right before the provider spawn, with nothing awaited in between: a closed pane starts nothing. ctx.paneAgentLaunch?.refuseIfClosed() + // Switch point: a pane main closed or took back while this spawn waited starts nothing. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + refuseClosedPaneSpawn(deps.store, args.tabId, args.leafId) + } await executePtyIpcSpawn(ctx) const committed = await commitPtyIpcSpawn(ctx) endPaneAgentLaunch( diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts index 6a760512d7c3..8f8c7a0bca7e 100644 --- a/src/main/ipc/pty/pane/spawn-reservation.ts +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -82,6 +82,17 @@ export function clearPaneSpawnReservation( } } +/** Whether a window or runtime spawn of this pane has begun and not yet bound or failed. */ +export function isPaneSpawnInFlight(worktreeId: string, paneKey: string): boolean { + for (const ownerKey of paneSpawnReservationsByOwnerKey.keys()) { + const parsed: unknown = JSON.parse(ownerKey) + if (Array.isArray(parsed) && parsed[1] === worktreeId && parsed[2] === paneKey) { + return true + } + } + return false +} + export function makePaneSpawnReservationKey( worktreeId: string | undefined, connectionId: string | null | undefined, diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts index 410fc2507b7d..54fa4b8b1f7b 100644 --- a/src/main/ipc/pty/provider/liveness.ts +++ b/src/main/ipc/pty/provider/liveness.ts @@ -10,6 +10,7 @@ import { KEEP_HISTORY_STOP_SETTLE_MS } from '../delivery/visibility-state' import { clearProviderPtyState } from './state-cleanup' +import type { PtyRetirementEvidence } from '../../../pty-exit-proof' export function normalizeNodePtySpawnError(err: unknown): Error { const rawMessage = err instanceof Error ? err.message : String(err) @@ -102,13 +103,14 @@ export async function verifyPtyStopped( export function finishPtyShutdown( id: string, connectionId: string | null | undefined, - store: Store | undefined + store: Store | undefined, + evidence: PtyRetirementEvidence ): string | undefined { const incarnationId = ptyIncarnationById.get(id) clearProviderPtyState(id) if (connectionId) { // Keeps any relay-era undelivered stop on the lease: only host evidence may retire it. - store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), evidence) } ptyOwnership.delete(id) return incarnationId diff --git a/src/main/ipc/pty/runtime/kill-exit-proof.test.ts b/src/main/ipc/pty/runtime/kill-exit-proof.test.ts new file mode 100644 index 000000000000..d63fa981fea4 --- /dev/null +++ b/src/main/ipc/pty/runtime/kill-exit-proof.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { PtyExitProof, PtyRetirementWithoutProof } from '../../../pty-exit-proof' +import { publishSyntheticExit } from './kill' + +function harness() { + const calls: string[] = [] + const runtime = { + markPtyLivenessUnverifiable: (_id: string, reason: string) => { + calls.push(`unverifiable:${reason}`) + }, + onPtyExit: () => { + calls.push('exit') + } + } + const sendPtyExitToRenderer = vi.fn() + const deps = { + runtime, + rememberSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer + } + return { calls, deps, sendPtyExitToRenderer } +} + +describe('a synthetic PTY exit', () => { + it('reads unverifiable, before the exit lands, when it rests on no proof', () => { + const { calls, deps, sendPtyExitToRenderer } = harness() + const unproven = PtyRetirementWithoutProof.because('gone') + publishSyntheticExit(deps, 'pty-1', -1, 'inc-1', unproven) + expect(calls).toEqual(['unverifiable:gone', 'exit']) + expect(sendPtyExitToRenderer).toHaveBeenCalledWith({ + id: 'pty-1', + code: -1, + incarnationId: 'inc-1' + }) + }) + + it('leaves the liveness verdict to the exit when the host proved it', () => { + const { calls, deps } = harness() + publishSyntheticExit(deps, 'pty-1', 0, undefined, PtyExitProof.from('absent')) + expect(calls).toEqual(['exit']) + }) +}) diff --git a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged-exit.test.ts b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged-exit.test.ts new file mode 100644 index 000000000000..4d9356623484 --- /dev/null +++ b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged-exit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { PtyExitProof } from '../../../pty-exit-proof' + +const bridge = vi.hoisted(() => ({ proven: true })) + +vi.mock('../../../ssh/legacy-relay/legacy-relay-bridge-registry', () => ({ + closeLegacyRelayPane: vi.fn(), + isLegacyRelayBridgedPty: () => true, + stopLegacyRelayPaneAndWait: async () => bridge.proven +})) + +const { stopAndWaitPtyFromRuntimeController } = await import('./kill') + +describe('a proven stop-and-wait of a terminal an older relay still runs', () => { + it('tells the runtime the terminal exited, without tombstoning the lease the bridge retired', async () => { + for (const proven of [true, false]) { + bridge.proven = proven + const deps = { + runtime: { markPtyStopRequested: vi.fn(), onPtyExit: vi.fn() }, + store: null, + getLocalPtyProviderStartupPromise: () => null, + shutdownProviderAndDetectExit: vi.fn(), + finishPtyShutdown: vi.fn(() => undefined), + rememberSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + const ptyId = toAppSshPtyId('ssh-box', 'pty2:epoch:1') + await expect( + stopAndWaitPtyFromRuntimeController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps. + deps as unknown as PtyRuntimeControllerDeps, + ptyId, + { deadlineMs: Date.now() + 1_000 } + ) + ).resolves.toBe(proven) + if (proven) { + expect(deps.finishPtyShutdown).toHaveBeenCalledWith( + ptyId, + null, + null, + PtyExitProof.from('absent') + ) + expect(deps.runtime.onPtyExit).toHaveBeenCalledWith(ptyId, 0, undefined) + } else { + expect(deps.finishPtyShutdown).not.toHaveBeenCalled() + expect(deps.runtime.onPtyExit).not.toHaveBeenCalled() + } + } + }) +}) diff --git a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts index d12b65e5395a..545097d0d4de 100644 --- a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts +++ b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshRemotePtyLease } from '../../../../shared/ssh-types' -import { installLegacyRelayBridge } from '../../../ssh/legacy-relay/legacy-relay-bridge-registry' +import { + closeLegacyRelayPane, + installLegacyRelayBridge +} from '../../../ssh/legacy-relay/legacy-relay-bridge-registry' +import { stopRendererOwnedPty, type PtyKillIpcDeps } from '../ipc/renderer-kill' import type { PtyRuntimeControllerDeps } from './controller-deps' -import { killPtyFromRuntimeController } from './kill' +import { killPtyFromRuntimeController, stopAndWaitPtyFromRuntimeController } from './kill' const TARGET = 'ssh-box' const lease: SshRemotePtyLease = { @@ -21,6 +25,7 @@ describe('a generic kill of a terminal an older relay still runs', () => { installLegacyRelayBridge({ leases: () => [lease], retireLease, + recordStop: vi.fn(), // Disconnected: the bridge can only record the stop for the next connect. connection: () => null, onPanesChanged: () => {}, @@ -50,3 +55,134 @@ describe('a generic kill of a terminal an older relay still runs', () => { installLegacyRelayBridge(null) }) }) + +describe('a tab close of a terminal an older relay still runs', () => { + it('hands the stop to the bridge instead of tombstoning the lease the bridge needs', async () => { + const leases: SshRemotePtyLease[] = [{ ...lease }] + const store = { + markSshRemotePtyLease: vi.fn((_target: string, relayPtyId: string) => { + for (const row of leases) { + if (row.ptyId === relayPtyId) { + row.state = 'terminated' + } + } + }) + } + installLegacyRelayBridge({ + leases: () => leases, + retireLease: vi.fn(), + recordStop: vi.fn(), + // Disconnected, so the SSH provider is unregistered: the bridge records the stop. + connection: () => null, + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + const deps = { + store, + getLocalPtyProviderStartupPromise: () => undefined, + shutdownProviderAndDetectExit: vi.fn(), + rememberSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps. + await stopRendererOwnedPty(deps as unknown as PtyKillIpcDeps, { id: ptyId }) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + expect(deps.shutdownProviderAndDetectExit).not.toHaveBeenCalled() + // The transport's own close that follows still reaches a pending stop, not `not-bridged`. + await expect(closeLegacyRelayPane(ptyId)).resolves.toEqual({ status: 'unverifiable' }) + installLegacyRelayBridge(null) + }) + + it('refuses a hibernation stop without recording a stop that would outlive it', async () => { + const recordStop = vi.fn() + const store = { markSshRemotePtyLease: vi.fn() } + installLegacyRelayBridge({ + leases: () => [lease], + retireLease: vi.fn(), + recordStop, + connection: () => null, + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) + const deps = { + store, + getLocalPtyProviderStartupPromise: () => undefined, + shutdownProviderAndDetectExit: vi.fn(), + rememberSyntheticKillExit: vi.fn(), + sendPtyExitToRenderer: vi.fn() + } + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps. + stopRendererOwnedPty(deps as unknown as PtyKillIpcDeps, { + id: toAppSshPtyId(TARGET, lease.ptyId), + keepHistory: true + }) + ).rejects.toThrow('legacy_relay_bridged_pty_cannot_sleep') + expect(recordStop).not.toHaveBeenCalled() + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + installLegacyRelayBridge(null) + }) +}) + +describe('a stop-and-wait of a terminal an older relay still runs', () => { + function install(recordStop = vi.fn()) { + installLegacyRelayBridge({ + leases: () => [lease], + retireLease: vi.fn(), + recordStop, + // Disconnected, so no SSH provider is registered for the target. + connection: () => null, + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) + const deps = { + runtime: null, + store: null, + getLocalPtyProviderStartupPromise: () => null, + shutdownProviderAndDetectExit: vi.fn(), + finishPtyShutdown: vi.fn() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch reads only these deps. + return deps as unknown as PtyRuntimeControllerDeps & { + finishPtyShutdown: ReturnType + } + } + + it('records the stop with the bridge instead of tombstoning its lease (CLI close, worktree delete)', async () => { + const recordStop = vi.fn() + const deps = install(recordStop) + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + await expect( + stopAndWaitPtyFromRuntimeController(deps, ptyId, { deadlineMs: Date.now() + 1_000 }) + ).resolves.toBe(false) + expect(deps.finishPtyShutdown).not.toHaveBeenCalled() + expect(recordStop).toHaveBeenCalledWith( + TARGET, + lease.ptyId, + expect.objectContaining({ attempts: 0 }) + ) + installLegacyRelayBridge(null) + }) + + it('leaves a reversible stopโ€™s pane live, with no durable stop recorded', async () => { + const recordStop = vi.fn() + const deps = install(recordStop) + const ptyId = toAppSshPtyId(TARGET, lease.ptyId) + await expect( + stopAndWaitPtyFromRuntimeController(deps, ptyId, { keepHistory: true }) + ).resolves.toBe(false) + expect(deps.finishPtyShutdown).not.toHaveBeenCalled() + expect(recordStop).not.toHaveBeenCalled() + installLegacyRelayBridge(null) + }) +}) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 5c281e35fcf2..5aeee9bdede2 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -8,26 +8,60 @@ import { } from '../provider/registry' import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness' import type { PtyRuntimeControllerDeps } from './controller-deps' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { errorMessage } from '../../../../shared/error-message' +import { + PtyExitProof, + PtyRetirementWithoutProof, + type PtyRetirementEvidence +} from '../../../pty-exit-proof' import { closeLegacyRelayPane, - isLegacyRelayBridgedPty + isLegacyRelayBridgedPty, + stopLegacyRelayPaneAndWait } from '../../../ssh/legacy-relay/legacy-relay-bridge-registry' // Bounds the wait for an observed exit to reach the runtime record when the caller set no deadline. const OBSERVED_EXIT_RECORD_WAIT_MS = 10_000 -function publishSyntheticExit( - deps: PtyRuntimeControllerDeps, +/** Tells the runtime and renderer a PTY ended that no provider exit reported. Without proof its + * liveness reads `unverifiable`, never `exited`. */ +export function publishSyntheticExit( + deps: Pick & { + runtime?: Pick + }, ptyId: string, code: number, - incarnationId: string | undefined + incarnationId: string | undefined, + evidence: PtyRetirementEvidence ): void { + if (evidence instanceof PtyRetirementWithoutProof) { + deps.runtime?.markPtyLivenessUnverifiable?.(ptyId, evidence.unverifiableReason) + } deps.runtime?.onPtyExit(ptyId, code, incarnationId) deps.rememberSyntheticKillExit(ptyId, incarnationId) deps.sendPtyExitToRenderer({ id: ptyId, code, ...(incarnationId ? { incarnationId } : {}) }) } +/** + * COMPAT(legacy-relay-bridge, revisit 2027-01-15): an older relay's shell stops through the + * bridge, which retires its lease only on the relay's proof; tombstoning it here would orphan it. + * Every stop route checks this first, before reading the (absent) SSH provider. A reversible stop + * is refused: the bridge's recorded stop would outlive it and kill a shell the pane wakes back to. + */ +export function stopLegacyRelayBridgedPty( + ptyId: string, + connectionId: string | null, + reversible = false +): Promise | null { + if (!connectionId || !isLegacyRelayBridgedPty(ptyId)) { + return null + } + return reversible + ? Promise.reject(new Error('legacy_relay_bridged_pty_cannot_sleep')) + : closeLegacyRelayPane(ptyId) +} + export function killPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string @@ -42,10 +76,7 @@ export function killPtyFromRuntimeController( } = deps runtime?.markPtyStopRequested?.(ptyId) const connectionId = getPtySshConnectionId(ptyId) - // COMPAT(legacy-relay-bridge, revisit 2027-01-15): an older relay's shell stops through the - // bridge, which retires its lease only on the relay's proof; tombstoning it here would orphan it. - if (connectionId && isLegacyRelayBridgedPty(ptyId)) { - void closeLegacyRelayPane(ptyId) + if (stopLegacyRelayBridgedPty(ptyId, connectionId)) { return true } // Why: close runtime tails without clearing provider ownership, so @@ -67,9 +98,9 @@ export function killPtyFromRuntimeController( // Why: runtime/CLI close can target a detached SSH PTY after its // provider was unregistered. Tombstone the lease so reconnect does // not revive a terminal the user explicitly closed. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - publishSyntheticExit(deps, ptyId, -1, incarnationId) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + const unproven = PtyRetirementWithoutProof.because(SSH_PROVIDER_UNREGISTERED_REASON) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store, unproven) + publishSyntheticExit(deps, ptyId, -1, incarnationId, unproven) } return false } @@ -77,17 +108,21 @@ export function killPtyFromRuntimeController( void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) .then((providerExitObserved) => { const retired = retiredRejectedPtyIds.has(ptyId) - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + const proof = PtyExitProof.from( + providerExitObserved ? 'exit-observed' : 'shutdown-acknowledged' + ) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store, proof) if (!providerExitObserved && !retired) { - publishSyntheticExit(deps, ptyId, -1, incarnationId) + publishSyntheticExit(deps, ptyId, -1, incarnationId, proof) } }) .catch((err) => { const retired = retiredRejectedPtyIds.has(ptyId) if (isPtyAlreadyGoneError(err)) { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + const proof = PtyExitProof.from('absent') + const incarnationId = finishPtyShutdown(ptyId, connectionId, store, proof) if (!retired) { - publishSyntheticExit(deps, ptyId, -1, incarnationId) + publishSyntheticExit(deps, ptyId, -1, incarnationId, proof) } return } @@ -112,14 +147,14 @@ export function retireRejectedPtyFromRuntimeController( const { runtime, store, rememberRetiredRejectedPty, finishPtyShutdown } = deps rememberRetiredRejectedPty(ptyId) if (!stopConfirmed) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, + const unproven = PtyRetirementWithoutProof.because( 'a follow-up stop was issued but its outcome could not be verified' ) if (!ptyOwnership.has(ptyId)) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, unproven.unverifiableReason) return } - publishSyntheticExit(deps, ptyId, -1, ptyIncarnationById.get(ptyId)) + publishSyntheticExit(deps, ptyId, -1, ptyIncarnationById.get(ptyId), unproven) return } // Why: a completed stop already cleared provider state, tombstoned the lease and told the @@ -129,12 +164,14 @@ export function retireRejectedPtyFromRuntimeController( runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) return } + const proof = PtyExitProof.from('stop-confirmed') const connectionId = getPtySshConnectionId(ptyId) - publishSyntheticExit(deps, ptyId, 0, finishPtyShutdown(ptyId, connectionId, store)) + publishSyntheticExit(deps, ptyId, 0, finishPtyShutdown(ptyId, connectionId, store, proof), proof) } /** - * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`. + * Deliberately records no undelivered-stop intent, unlike `killPtyFromRuntimeController`, except + * the legacy relay bridge's for a non-reversible stop (the tab is given up for good). * * Only a caller that gives the PTY up for good may leave a replayable kill order behind. This one * hands its failures back instead: worktree sleep marks these stops reversible and leaves the pane @@ -155,6 +192,24 @@ export async function stopAndWaitPtyFromRuntimeController( } = deps runtime?.markPtyStopRequested?.(ptyId) const connectionId = getPtySshConnectionId(ptyId) + if (connectionId && isLegacyRelayBridgedPty(ptyId)) { + // A reversible stop must not leave the bridge's durable stop behind, so the pane stays live. + if (opts?.keepHistory) { + return false + } + const deadline = opts?.deadlineMs ?? Date.now() + OBSERVED_EXIT_RECORD_WAIT_MS + const stopped = await Promise.race([ + stopLegacyRelayPaneAndWait(ptyId, deadline), + delay(Math.max(1, deadline - Date.now())).then(() => false) + ]) + if (stopped) { + // The bridge retired the lease and holds no pane: the relay reported the exit, or the + // listing lacks it. The runtime learns it here. + const proof = PtyExitProof.from('absent') + publishSyntheticExit(deps, ptyId, 0, finishPtyShutdown(ptyId, null, store, proof), proof) + } + return stopped + } // Why: destructive teardown threads one absolute deadline through every await // below; each RPC leaf converts it to the remaining time when it issues, so // sequential RPCs share the budget and cannot overrun the sweep deadline. @@ -189,9 +244,9 @@ export async function stopAndWaitPtyFromRuntimeController( if (connectionId) { // Why: an absent SSH provider means there is no live target left to // await, but the relay lease must still be tombstoned. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - publishSyntheticExit(deps, ptyId, -1, incarnationId) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + const unproven = PtyRetirementWithoutProof.because(SSH_PROVIDER_UNREGISTERED_REASON) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store, unproven) + publishSyntheticExit(deps, ptyId, -1, incarnationId, unproven) } return false } @@ -217,11 +272,12 @@ export async function stopAndWaitPtyFromRuntimeController( console.warn(`[pty] Failed to verify PTY ${ptyId} stopped: ${errorMessage(err)}`) return false } - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + const proof = PtyExitProof.from(providerExitObserved ? 'exit-observed' : 'absent') + const incarnationId = finishPtyShutdown(ptyId, connectionId, store, proof) if (!providerExitObserved) { // The owning provider's fresh inventory observed absence, so this is a // death certificate even when its exit event was missed. - publishSyntheticExit(deps, ptyId, 0, incarnationId) + publishSyntheticExit(deps, ptyId, 0, incarnationId, proof) } else { // Why: an SSH exit frame reaches the runtime only after its output intake drains, so callers // reading the verdict right after this stop would otherwise see a still-connected PTY. diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 88fe0d9fb187..7a367e8c2e21 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -28,6 +28,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot } from '../git/repo' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { createRepoHandlerHarness, resetLocalRepoMocks } from './repos-remote-test-harness' +import { repoTarget } from '../persistence/repo-host-target-matcher.test-fixture' const { handleMock, @@ -262,7 +263,7 @@ describe('repos:add + repos:clone', () => { setupMethod: 'imported-existing-folder' }) - expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, existing.id, { + expect(mockStore.updateRepo).toHaveBeenNthCalledWith(1, repoTarget(existing.id), { upstream: { owner: 'acme', repo: 'orca', @@ -275,7 +276,7 @@ describe('repos:add + repos:clone', () => { const added: Record[] = [] mockStore.getRepos.mockImplementation(() => added) mockStore.addRepo.mockImplementation((repo: Record) => added.push(repo)) - mockStore.updateRepo.mockImplementation((id, updates) => { + mockStore.updateRepo.mockImplementation(({ id }: { id: string }, updates) => { const repo = added.find((entry) => entry.id === id) if (!repo) { return null @@ -341,6 +342,72 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) + it('refuses to relabel a folder whose own GitHub remote names another repository (#30663)', async () => { + const added: Record[] = [] + mockStore.getRepos.mockImplementation(() => added) + mockStore.addRepo.mockImplementation((repo: Record) => added.push(repo)) + reposMocks.gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'remote' && argv[1] === '-v' + ? { + stdout: + 'origin\thttps://github.com/acme/repo-b.git (fetch)\norigin\thttps://github.com/acme/repo-b.git (push)\n' + } + : { stdout: '' } + ) + + try { + await expect( + handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: 'github:acme/repo-a', + projectProviderIdentity: { provider: 'github', owner: 'acme', repo: 'repo-a' }, + hostId: 'local', + path: '/tmp/repo-b', + kind: 'git' + }) + ).rejects.toThrow(/points at acme\/repo-b, not acme\/repo-a/) + } finally { + reposMocks.gitExecFileAsyncMock.mockReset() + } + + expect(mockStore.updateRepo).not.toHaveBeenCalled() + expect(mockStore.removeProjectForHost).toHaveBeenCalledWith(added[0]?.id, 'local') + }) + + it('re-reads an already-registered folder whose stored remote is still pending (#30663)', async () => { + const registered = { + id: 'repo-b', + path: '/tmp/repo-b', + displayName: 'repo-b', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 1, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([registered]) + mockStore.removeProjectForHost.mockClear() + reposMocks.gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'remote' && argv[1] === '-v' + ? { stdout: 'origin\tgit@github.com:acme/repo-b.git (fetch)\n' } + : { stdout: '' } + ) + + try { + await expect( + handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: 'github:acme/repo-a', + projectProviderIdentity: { provider: 'github', owner: 'acme', repo: 'repo-a' }, + hostId: 'local', + path: '/tmp/repo-b', + kind: 'git' + }) + ).rejects.toThrow(/points at acme\/repo-b, not acme\/repo-a/) + } finally { + reposMocks.gitExecFileAsyncMock.mockReset() + } + + expect(mockStore.updateRepo).not.toHaveBeenCalled() + expect(mockStore.removeProjectForHost).not.toHaveBeenCalled() + }) + it('refuses project setup on an SSH target with the not-connected text and adds no repo', async () => { await expect( handlers.get('projectHostSetups:setupExistingFolder')!(null, { @@ -363,6 +430,7 @@ describe('repos:add + repos:clone', () => { badgeColor: '#22c55e', worktreeBasePath: '../worktrees' } + mockStore.getRepo.mockReturnValue(updated) mockStore.updateRepo.mockReturnValue(updated) const result = await handlers.get('repos:update')!(null, { @@ -371,7 +439,7 @@ describe('repos:add + repos:clone', () => { }) expect(result).toBe(updated) - expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, { + expect(mockStore.updateRepo).toHaveBeenCalledWith(repoTarget(updated.id), { worktreeBasePath: '../worktrees' }) expect(mockStore.getRepos).not.toHaveBeenCalled() @@ -388,6 +456,7 @@ describe('repos:add + repos:clone', () => { badgeColor: '#22c55e', agentWorktreeVisibility: 'show' } + mockStore.getRepo.mockReturnValue(updated) mockStore.updateRepo.mockReturnValue(updated) const result = await handlers.get('repos:update')!(null, { @@ -396,7 +465,7 @@ describe('repos:add + repos:clone', () => { }) expect(result).toBe(updated) - expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, { + expect(mockStore.updateRepo).toHaveBeenCalledWith(repoTarget(updated.id), { agentWorktreeVisibility: 'show' }) }) @@ -409,6 +478,7 @@ describe('repos:add + repos:clone', () => { kind: 'git', badgeColor: '#22c55e' } + mockStore.getRepo.mockReturnValue(updated) mockStore.updateRepo.mockReturnValue(updated) await handlers.get('repos:update')!(null, { @@ -425,7 +495,7 @@ describe('repos:add + repos:clone', () => { } }) - expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, { + expect(mockStore.updateRepo).toHaveBeenCalledWith(repoTarget(updated.id), { customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team' }], worktreeVisibilitySourcePreferences: { builtIn: { claude: 'show' }, diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index b60bff50181b..b279049a9041 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -38,6 +38,7 @@ import { resetLocalRepoMocks, waitForAssertion } from './repos-remote-test-harness' +import { repoTarget } from '../persistence/repo-host-target-matcher.test-fixture' const { handleMock, @@ -197,7 +198,7 @@ describe('repos:add + repos:clone', () => { expect(mockStore.updateRepo).not.toHaveBeenCalled() expect(result).toEqual(current) } else { - expect(mockStore.updateRepo).toHaveBeenCalledWith(current.id, { + expect(mockStore.updateRepo).toHaveBeenCalledWith(repoTarget(current.id), { kind: 'git', folderUpgradeGitRootPath: clonePath, projectHostSetupMethod: 'cloned' diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 383c3300ee1c..0ae09ccb9989 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -30,7 +30,7 @@ export type ReposIpcMocks = { | 'getSshTargets', ReposIpcSpy > & { - updateRepo: Mock<(repoId: string, updates: Record) => unknown> + updateRepo: Mock<(target: { id: string }, updates: Record) => unknown> } gitSpawnMock: ReposIpcSpy gitSpawnAfterWindowsEnvironmentReadyMock: ReposIpcSpy diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 1c2b5d8473af..ef7520e47b24 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -63,7 +63,7 @@ export function registerRepoHandlers( ipcMain.removeHandler('sparsePresets:save') ipcMain.removeHandler('sparsePresets:remove') - registerRepoCatalogHandlers(mainWindow, store) + registerRepoCatalogHandlers(mainWindow, store, runtime) registerProjectHostSetupHandlers(mainWindow, store) registerRepoCreationHandlers(mainWindow, store) registerProjectGroupHandlers(mainWindow, store) diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index efc995cfd8f5..7c42ce75d3a2 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -14,6 +14,8 @@ import type { } from '../../../shared/project-types' import { getProjectIdForProviderIdentity } from '../../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup' +import { assertImportedFolderRemoteMatchesProject } from '../../../shared/imported-folder-project-identity' +import { readCurrentGitRemoteIdentity } from '../../repo-git-remote-identity' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { updateProjectHostSetupWithProvenKind } from '../../folder-repo-git-conversion' @@ -46,6 +48,7 @@ function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetup function alignRepoWithRequestedProject( store: Store, repo: Repo, + folderRemote: Pick, projectId: string, setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder', requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity'] @@ -63,8 +66,9 @@ function alignRepoWithRequestedProject( if (!identity || getProjectIdForProviderIdentity(identity) !== projectId) { throw new Error('Imported folder does not match the selected project identity.') } + assertImportedFolderRemoteMatchesProject(folderRemote, identity) // Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it. - const updated = store.updateRepo(repo.id, { + const updated = store.updateRepo(repo, { upstream: { owner: identity.owner, repo: identity.repo, @@ -77,7 +81,7 @@ function alignRepoWithRequestedProject( repo = updated setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) } - const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) + const updated = store.updateRepo(repo, { projectHostSetupMethod: setupMethod }) if (!updated) { throw new Error( `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` @@ -114,11 +118,11 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor rawArgs, 'project_host_setup_update_invalid_args' ) - assertWslMoveHostSetupIdWritable(store, args.setupId) + assertWslMoveHostSetupIdWritable(store, args) const result = await updateProjectHostSetupWithProvenKind( store, (setupArgs) => { - assertWslMoveHostSetupIdWritable(store, setupArgs.setupId) + assertWslMoveHostSetupIdWritable(store, setupArgs) return store.updateProjectHostSetup(setupArgs) }, args @@ -143,7 +147,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor rawArgs, 'project_host_setup_delete_invalid_args' ) - assertWslMoveHostSetupIdWritable(store, args.setupId) + assertWslMoveHostSetupIdWritable(store, args) const result = store.deleteProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) @@ -180,11 +184,16 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor if ('error' in result) { throw new Error(result.error) } + // Why: a registered row's stored remote can predate a remote the user has since added. + const folderRemote = result.alreadyExisted + ? await readCurrentGitRemoteIdentity(result.repo) + : result.repo let aligned: ProjectHostSetupResult try { aligned = alignRepoWithRequestedProject( store, result.repo, + folderRemote, args.projectId, args.setupMethod, args.projectProviderIdentity diff --git a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts index c4e566178b35..62658d5c57f4 100644 --- a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts @@ -146,7 +146,7 @@ describe('projects:update once the WSL host owns a repo', () => { it('lets project-wide settings change again', async () => { store.addRepo(repo('repo-1')) seedWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed')) - registerRepoCatalogHandlers(new BrowserWindow(), store) + registerRepoCatalogHandlers(new BrowserWindow(), store, { removeProject: vi.fn() }) const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('repo-1')) const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } } diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 071c8d9dfd96..0db9c5b89fd3 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -2,11 +2,17 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import type { Store } from '../../persistence' import type { Project, ProjectUpdateArgs } from '../../../shared/project-types' -import { normalizeExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { enrichRepoGitUsernames } from '../../repo-git-username-enrichment' import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { notifyReposChanged } from './repos-changed-notification' +import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from '../worktree-base-directory-watcher' import { ProjectUpdateIpcArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' import { visibleRepos } from '../../ssh/orcad-retained-source' import { @@ -15,7 +21,11 @@ import { isWslMoveSourceHost } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard' -export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: Store): void { +export function registerRepoCatalogHandlers( + mainWindow: BrowserWindow, + store: Store, + runtime: Pick +): void { // Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh // closure per list call would stack up (and re-broadcast) for the length of a slow sweep. const broadcastReposChanged = (): void => notifyReposChanged(mainWindow) @@ -93,6 +103,17 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St if (isWslMoveSourceHost(hostId)) { assertWslMoveProjectWritable(store, { repoId: args.repoId }) } + const row = store + .getRepos() + .find((repo) => repo.id === args.repoId && getRepoExecutionHostId(repo) === hostId) + // Why the runtime's removal: this process runs the project's local and SSH sessions, and only + // the host's own sweep stops the ones no renderer lists. A paired server's row is residue. + if (row && parseExecutionHostId(hostId)?.kind !== 'runtime') { + // It notifies the renderer and paired clients itself. + await runtime.removeProject(row) + scheduleCurrentWorktreeBaseDirectoryWatcherSync() + return + } store.removeProjectForHost(args.repoId, hostId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index b70f5a0fc0f4..66d83eb0097e 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -264,7 +264,7 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor isFolderRepo(current) && getRepoExecutionHostId(current) === getRepoExecutionHostId(existing) ) { - const updated = store.updateRepo(current.id, { + const updated = store.updateRepo(current, { kind: 'git', ...conversion, projectHostSetupMethod: 'cloned' diff --git a/src/main/ipc/repos/repo-ipc-arg-schemas.ts b/src/main/ipc/repos/repo-ipc-arg-schemas.ts index 3093ed0024bc..9c0ff14dca2b 100644 --- a/src/main/ipc/repos/repo-ipc-arg-schemas.ts +++ b/src/main/ipc/repos/repo-ipc-arg-schemas.ts @@ -65,19 +65,21 @@ export const ProjectUpdateIpcArgs = z.object({ }) }) +const IpcExecutionHostId = z + .string() + .min(1) + .transform((value, ctx) => { + const hostId = normalizeExecutionHostId(value) + if (!hostId) { + ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) + return z.NEVER + } + return hostId + }) + export const ProjectHostSetupCreateIpcArgs = z.object({ projectId: z.string().min(1), - hostId: z - .string() - .min(1) - .transform((value, ctx) => { - const hostId = normalizeExecutionHostId(value) - if (!hostId) { - ctx.addIssue({ code: 'custom', message: 'Invalid host ID' }) - return z.NEVER - } - return hostId - }), + hostId: IpcExecutionHostId, setupId: z.string().min(1).optional(), path: z.string().optional(), kind: z.enum(['git', 'folder']).optional(), @@ -90,6 +92,7 @@ export const ProjectHostSetupCreateIpcArgs = z.object({ export const ProjectHostSetupUpdateIpcArgs = z.object({ setupId: z.string().min(1), + hostId: IpcExecutionHostId.optional(), updates: z.object({ displayName: z.string().optional(), path: z.string().optional(), @@ -104,7 +107,8 @@ export const ProjectHostSetupUpdateIpcArgs = z.object({ }) export const ProjectHostSetupDeleteIpcArgs = z.object({ - setupId: z.string().min(1) + setupId: z.string().min(1), + hostId: IpcExecutionHostId.optional() }) const FolderWorkspaceLinkedTaskArgs = WorkspaceLinkedItemSchema.nullable() diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts index 1b8ec667fa5a..fdc80d51e104 100644 --- a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -112,7 +112,7 @@ describe('projects:update under a WSL move', () => { store.addRepo(repo('repo-1')) store.addRepo(repo('repo-2')) seedWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced')) - registerRepoCatalogHandlers(new BrowserWindow(), store) + registerRepoCatalogHandlers(new BrowserWindow(), store, { removeProject: vi.fn() }) const projectFor = (repoId: string) => store.getProjects().find((project) => project.sourceRepoIds.includes(repoId)) const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } } diff --git a/src/main/ipc/repos/repo-remove-for-host-teardown.test.ts b/src/main/ipc/repos/repo-remove-for-host-teardown.test.ts new file mode 100644 index 000000000000..55f05f054b29 --- /dev/null +++ b/src/main/ipc/repos/repo-remove-for-host-teardown.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + handlers.set(channel, handler) + } +})) + +vi.mock('../worktree-base-directory-watcher', () => ({ + scheduleCurrentWorktreeBaseDirectoryWatcherSync: vi.fn() +})) + +const { registerRepoCatalogHandlers } = await import('./repo-catalog-handlers') + +const repo = (fields: Partial): Repo => ({ + id: 'repo-1', + path: '/work/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 1, + ...fields +}) + +function register(repos: Repo[]) { + const store = { getRepos: () => repos, removeProjectForHost: vi.fn() } + const runtime = { removeProject: vi.fn(async () => ({ removed: true as const })) } + const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removeForHost reads only getRepos and removeProjectForHost. + registerRepoCatalogHandlers(mainWindow as never, store as never, runtime) + return { store, runtime, remove: handlers.get('repos:removeForHost')! } +} + +describe('repos:removeForHost', () => { + beforeEach(() => handlers.clear()) + + it("removes this computer's project through the runtime, which stops its sessions first", async () => { + const local = repo({}) + const ssh = repo({ executionHostId: 'ssh:box', connectionId: 'box' }) + const { store, runtime, remove } = register([local, ssh]) + await remove(null, { repoId: 'repo-1', hostId: 'ssh:box' }) + expect(runtime.removeProject).toHaveBeenCalledWith(ssh) + expect(store.removeProjectForHost).not.toHaveBeenCalled() + }) + + it("only drops a paired server's residue row, which runs nothing here", async () => { + const paired = repo({ executionHostId: 'runtime:env-1' }) + const { store, runtime, remove } = register([paired]) + await remove(null, { repoId: 'repo-1', hostId: 'runtime:env-1' }) + expect(runtime.removeProject).not.toHaveBeenCalled() + expect(store.removeProjectForHost).toHaveBeenCalledWith('repo-1', 'runtime:env-1') + }) +}) diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index 53b5fe0ff607..ba95477f644b 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import type { Store } from '../../persistence' import type { Repo } from '../../../shared/repo-types' +import type { RepoHostTarget } from '../../../shared/repo-host-identity' import { isFolderRepo } from '../../../shared/repo-kind' import type { ExecutionHostId } from '../../../shared/execution-host' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' @@ -227,26 +228,27 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor if (args.hostId && !hostId) { return null } + // Why: some renderer callers send only the id; they keep the first row with it, as before. + const target: RepoHostTarget | undefined = hostId + ? { id: args.repoId, executionHostId: hostId } + : store.getRepo(args.repoId) + if (!target) { + return null + } const write = (proven: Pick): Repo | null => { if (isWslMoveSourceHost(args.hostId)) { assertWslMoveProjectWritable(store, { repoId: args.repoId }) } - return hostId - ? store.updateRepo(args.repoId, { ...updates, ...proven }, hostId) - : store.updateRepo(args.repoId, { ...updates, ...proven }) + return store.updateRepo(target, { ...updates, ...proven }) } let updated: Repo | null if (updates.kind === 'git') { - const target = store + const targetHostId = getRepoExecutionHostId(target) + const row = store .getRepos() - .find( - (repo) => - repo.id === args.repoId && (!hostId || getRepoExecutionHostId(repo) === hostId) - ) + .find((repo) => repo.id === target.id && getRepoExecutionHostId(repo) === targetHostId) updated = - target && isFolderRepo(target) - ? await commitProvenRepoKindChange(store, target, write) - : write({}) + row && isFolderRepo(row) ? await commitProvenRepoKindChange(store, row, write) : write({}) } else { updated = write({}) } diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts index 29e85ceb6358..ced9890cce6a 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.test.ts @@ -19,6 +19,7 @@ import { pauseRemoteRuntimeSharedControlRetry, reconnectRemoteRuntimeSharedControlConnection, sendRemoteRuntimeSharedControlRequest, + sendRemoteRuntimeSharedControlRequestOnRuntime, subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections' import { getRemoteRuntimeSharedControlAnsweredAt } from './runtime-environment-shared-control-answers' @@ -145,6 +146,31 @@ describe('runtime environment shared-control connection cache', () => { await waitFor(() => server.connectionCount() === 2) subscription.close() }) + + it.each([ + ['the probed runtime', 'runtime-a', true], + ['a replacement runtime behind the same endpoint', 'runtime-b', false] + ])( + 'sends a runtime-pinned call only when its socket reached %s', + async (_label, reached, sent) => { + const server = await createSharedControlTestServer({ + resultForRequest: (method) => + method === 'status.get' ? { runtimeId: reached } : { method } + }) + + const response = await sendRemoteRuntimeSharedControlRequestOnRuntime( + ENVIRONMENT_ID, + server.pairing, + 'files.write', + { relativePath: 'a.ts' }, + 1000, + 'runtime-a' + ) + + expect(response === null).toBe(!sent) + expect(server.requests.some((request) => request.method === 'files.write')).toBe(sent) + } + ) }) async function waitFor(predicate: () => boolean, timeoutMs = 1_000): Promise { diff --git a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts index 07e6c6846118..91c3e6fc7804 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-request-connections.ts @@ -144,6 +144,51 @@ export function sendRemoteRuntimeSharedControlRequest( ) } +/** + * Sends only on the shared socket whose own `status.get` named `expectedRuntimeId`; null when it + * did not, or the socket changed before sending. Nothing is sent to another runtime either way. + */ +export async function sendRemoteRuntimeSharedControlRequestOnRuntime( + environmentId: string, + pairedConnection: PairedRuntimeConnection, + method: string, + params: unknown, + timeoutMs: number, + expectedRuntimeId: string, + envelope?: RuntimeOrchestrationEnvelope, + signal?: AbortSignal +): Promise | null> { + const shared = getSharedControlConnection(environmentId, pairedConnection) + let statusSocket: number | undefined + const status = await shared.request( + 'status.get', + undefined, + timeoutMs, + undefined, + signal, + () => { + statusSocket = shared.socketGeneration.peek() + } + ) + if (!status.ok || status.result.runtimeId !== expectedRuntimeId) { + return null + } + let socketChanged = false + try { + return await shared.request(method, params, timeoutMs, envelope, signal, () => { + socketChanged = shared.socketGeneration.peek() !== statusSocket + if (socketChanged) { + throw new Error('Runtime connection changed before sending') + } + }) + } catch (error) { + if (socketChanged) { + return null + } + throw error + } +} + export function subscribeRemoteRuntimeSharedControlRequest( environmentId: string, pairedConnection: PairedRuntimeConnection, diff --git a/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.test.ts b/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.test.ts new file mode 100644 index 000000000000..e12c011244f6 --- /dev/null +++ b/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { requestSharedControl } from '../../../shared/remote-runtime-shared-control-requests' +import type { SharedControlPendingRequest } from '../../../shared/remote-runtime-shared-control-types' +import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { getPreferredPairedRuntimeConnection } from '../../../shared/runtime-environments' +import type * as RemoteRuntimeClient from '../../../shared/remote-runtime-client' + +const oneShot = vi.hoisted(() => ({ statusRuntimeId: 'runtime-a', sent: new Array() })) +vi.mock('../../../shared/remote-runtime-client', async (importOriginal) => ({ + ...(await importOriginal()), + // The real sender asks `status.get` on the fresh socket and sends only if the check passes. + sendRemoteRuntimeRequestWithStatusPreflight: async ( + _connection: unknown, + method: string, + _params: unknown, + _timeoutMs: number, + validateStatus: (response: { ok: true; result: { runtimeId: string } }) => void + ) => { + validateStatus({ ok: true, result: { runtimeId: oneShot.statusRuntimeId } }) + oneShot.sent.push(method) + return { id: 'rpc', ok: true, result: { ok: true }, _meta: { runtimeId: 'runtime-a' } } + } +})) + +import { sendSupportRoutedCallOnRuntime } from './runtime-environment-runtime-pinned-send' + +const environment: KnownRuntimeEnvironment = { + id: 'env', + name: 'Environment', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: 'runtime-a', + endpoints: [ + { + id: 'ws', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'ws://host', + deviceToken: 'token', + publicKeyB64: 'key' + } + ], + preferredEndpointId: 'ws' +} +const route = { environment, connection: getPreferredPairedRuntimeConnection(environment) } + +describe('runtime-pinned support-routed sends', () => { + it.each([ + ['the probed runtime', 'runtime-a', true], + ['a replacement runtime', 'runtime-b', false] + ])( + 'sends over a fresh one-shot socket only when it reached %s', + async (_label, reached, sent) => { + oneShot.statusRuntimeId = reached + oneShot.sent = [] + + const response = await sendSupportRoutedCallOnRuntime( + route, + { method: 'files.write', params: {}, timeoutMs: 100 }, + 'runtime-a', + 'one-shot' + ) + + expect(response.ok).toBe(sent) + expect(oneShot.sent).toEqual(sent ? ['files.write'] : []) + if (!response.ok) { + expect(response.error.code).toBe('runtime_environment_changed') + } + } + ) + + it('rejects a shared-socket request without sending when the pre-send check fails', async () => { + const send = vi.fn() + const pendingRequests = new Map>() + + await expect( + requestSharedControl({ + pendingRequests, + deviceToken: 'device-token', + method: 'files.write', + params: undefined, + timeoutMs: 1000, + ensureReady: () => Promise.resolve(), + send, + beforeSend: () => { + throw new Error('Runtime connection changed before sending') + } + }) + ).rejects.toThrow('changed before sending') + expect(send).not.toHaveBeenCalled() + expect(pendingRequests.size).toBe(0) + }) +}) diff --git a/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.ts b/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.ts new file mode 100644 index 000000000000..59b3989787b4 --- /dev/null +++ b/src/main/ipc/runtime-environments/runtime-environment-runtime-pinned-send.ts @@ -0,0 +1,75 @@ +import type { PairedRuntimeConnection } from '../../../shared/paired-runtime-connection' +import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../../shared/remote-runtime-client' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../../shared/runtime-rpc-envelope' +import type { KnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' +import { sendRemoteRuntimeSharedControlRequestOnRuntime } from './runtime-environment-request-connections' +import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' + +type PinnedCall = { + method: string + params: unknown + timeoutMs: number + envelope?: RuntimeOrchestrationEnvelope + signal?: AbortSignal +} + +/** A one-shot socket that sends only after its own `status.get` names `expectedRuntimeId`; null otherwise. */ +async function sendOneShotRequestOnRuntime( + connection: PairedRuntimeConnection, + call: PinnedCall, + expectedRuntimeId: string +): Promise | null> { + let otherRuntime = false + try { + return await sendRemoteRuntimeRequestWithStatusPreflight( + connection, + call.method, + call.params, + call.timeoutMs, + (status) => { + otherRuntime = !status.ok || status.result.runtimeId !== expectedRuntimeId + if (otherRuntime) { + throw new Error('Runtime changed before sending') + } + }, + call.envelope, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, + call.signal + ) + } catch (error) { + if (otherRuntime) { + return null + } + throw error + } +} + +/** + * Sends a call shaped for one runtime only over a socket that runtime answered on. Why: the cached + * identity a support route accepts can outlive a server restart behind the same endpoint. + */ +export async function sendSupportRoutedCallOnRuntime( + route: { environment: KnownRuntimeEnvironment; connection: PairedRuntimeConnection }, + call: PinnedCall, + expectedRuntimeId: string, + transport: 'shared' | 'one-shot' +): Promise> { + const response = + transport === 'shared' + ? await sendRemoteRuntimeSharedControlRequestOnRuntime( + route.environment.id, + route.connection, + call.method, + call.params, + call.timeoutMs, + expectedRuntimeId, + call.envelope, + call.signal + ) + : await sendOneShotRequestOnRuntime(route.connection, call, expectedRuntimeId) + return response ?? runtimeEnvironmentChangedFailure(route.environment, call.method) +} diff --git a/src/main/ipc/runtime-environments/runtime-environment-support-routing.test.ts b/src/main/ipc/runtime-environments/runtime-environment-support-routing.test.ts index 605a91889b6e..7e96c34b30f9 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-support-routing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-support-routing.test.ts @@ -107,6 +107,36 @@ describe('runtime environment support routing', () => { expect(unsupported).not.toHaveBeenCalled() }) + it.each([ + ['the re-resolved pairing names a replacement runtime', 'replacement', 'runtime'], + ['the ready connection reached a replacement runtime', 'runtime', 'replacement'] + ])( + 'does not send a runtime-pinned call when %s', + async (_label, pairedRuntimeId, reachedRuntimeId) => { + supportsMock + .mockResolvedValueOnce({ kind: 'stale_incarnation' }) + .mockResolvedValueOnce(acceptedOutcome('capable', reachedRuntimeId)) + resolveEnvironmentMock.mockReturnValue(environment({ runtimeId: pairedRuntimeId })) + const supported = vi.fn().mockResolvedValue(success()) + const unsupported = vi.fn().mockResolvedValue(success()) + + const result = await routeRuntimeEnvironmentCallBySupport({ + userDataPath: '/profile', + initialEnvironment: environment(), + expectedRuntimeId: 'runtime', + method: 'files.write', + timeoutMs: 100, + supported, + unsupported, + markUsed: vi.fn() + }) + + expect(result).toMatchObject({ ok: false, error: { code: 'runtime_environment_changed' } }) + expect(supported).not.toHaveBeenCalled() + expect(unsupported).not.toHaveBeenCalled() + } + ) + it('delivers a response but suppresses identity writes after response-time invalidation', async () => { supportsMock.mockResolvedValue(acceptedOutcome('absent')) const pending = deferred>() @@ -153,7 +183,7 @@ describe('runtime environment support routing', () => { }) }) -function acceptedOutcome(verdict: 'capable' | 'absent') { +function acceptedOutcome(verdict: 'capable' | 'absent', runtimeId = 'runtime') { const environmentId = 'env' const pairing = { v: 2 as const, @@ -165,8 +195,8 @@ function acceptedOutcome(verdict: 'capable' | 'absent') { environmentId, pairedRuntimeConnectionFromOffer(pairing) ) - applyRuntimeEnvironmentCapabilityVerdict({ evidence, verdict, runtimeId: 'runtime' }) - return runtimeEnvironmentCapabilityOutcome(evidence, verdict, 'runtime') + applyRuntimeEnvironmentCapabilityVerdict({ evidence, verdict, runtimeId }) + return runtimeEnvironmentCapabilityOutcome(evidence, verdict, runtimeId) } function environment(overrides: Partial = {}): KnownRuntimeEnvironment { diff --git a/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts index 1dc7f26371a7..a8003e89e654 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-support-routing.ts @@ -32,6 +32,7 @@ import { getRemoteRuntimeSharedControlDiagnostics, subscribeRemoteRuntimeSharedControlRequest } from './runtime-environment-request-connections' +import { sendSupportRoutedCallOnRuntime } from './runtime-environment-runtime-pinned-send' type SupportRoute = { environment: KnownRuntimeEnvironment @@ -84,6 +85,7 @@ export function executeSupportRoutedCall(args: { params: unknown timeoutMs: number expectedPairingRevision?: number + expectedRuntimeId?: string envelope?: RuntimeOrchestrationEnvelope signal?: AbortSignal }): Promise> { @@ -93,27 +95,32 @@ export function executeSupportRoutedCall(args: { method: args.method, timeoutMs: args.timeoutMs, expectedPairingRevision: args.expectedPairingRevision, + expectedRuntimeId: args.expectedRuntimeId, signal: args.signal, supported: (route) => - sendRemoteRuntimeSharedControlRequestAbortable( - route.environment.id, - route.connection, - args.method, - args.params, - args.timeoutMs, - args.envelope, - args.signal - ), + args.expectedRuntimeId !== undefined + ? sendSupportRoutedCallOnRuntime(route, args, args.expectedRuntimeId, 'shared') + : sendRemoteRuntimeSharedControlRequestAbortable( + route.environment.id, + route.connection, + args.method, + args.params, + args.timeoutMs, + args.envelope, + args.signal + ), unsupported: (route) => - sendRemoteRuntimeRequestAbortable( - route.connection, - args.method, - args.params, - args.timeoutMs, - args.envelope, - args.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ), + args.expectedRuntimeId !== undefined + ? sendSupportRoutedCallOnRuntime(route, args, args.expectedRuntimeId, 'one-shot') + : sendRemoteRuntimeRequestAbortable( + route.connection, + args.method, + args.params, + args.timeoutMs, + args.envelope, + args.signal, + ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES + ), markUsed: (environmentId, response) => { if (response.ok) { markEnvironmentUsed(args.userDataPath, environmentId, { @@ -186,6 +193,8 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { method: string timeoutMs: number expectedPairingRevision?: number + /** The caller's request was shaped for this runtime; a replacement must never receive it. */ + expectedRuntimeId?: string signal?: AbortSignal supported: (route: SupportRoute) => Promise> unsupported: (route: SupportRoute) => Promise> @@ -196,7 +205,8 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { const revisionFailure = runtimeEnvironmentRevisionFailure( environment, args.expectedPairingRevision, - args.method + args.method, + args.expectedRuntimeId ) if (revisionFailure) { return revisionFailure @@ -210,6 +220,10 @@ export async function routeRuntimeEnvironmentCallBySupport(args: { outcome.kind !== 'stale_incarnation' && isRuntimeEnvironmentCapabilityOutcomeCurrent(outcome) ) { + // Why: checked after the connection is ready, just before sending, where a reconnect lands. + if (args.expectedRuntimeId !== undefined && outcome.runtimeId !== args.expectedRuntimeId) { + return runtimeEnvironmentChangedFailure(environment, args.method) + } const route = { environment, connection, outcome } const response = await (outcome.kind === 'supported' ? args.supported(route) diff --git a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts index a482211d82da..6c156c075c76 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts @@ -111,6 +111,7 @@ export async function callRuntimeEnvironment( params, timeoutMs: effectiveTimeoutMs, expectedPairingRevision: expectedEnvironmentPairingRevision, + expectedRuntimeId: options?.expectedEnvironmentRuntimeId, envelope: sharedControlEnvelope, signal: options?.signal }) diff --git a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts index a879409d7869..f877fc7c10b8 100644 --- a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts +++ b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts @@ -6,8 +6,10 @@ import type { SshTarget } from '../../../shared/ssh-types' import { closeTestStores, createSqliteTestStore } from '../../persistence-test-harness' import { Store } from '../../persistence/loading-store/store' import { OrcadHostUnsupportedError } from '../../orcad/orcad-host-unavailable' +import { RemoteNodeRuntimeNoexecError } from '../../ssh/orcad-remote-node-runtime-report' import { allowsDirectSshRelay, SshConnectionStore } from '../../ssh/connection/ssh-connection-store' import { resolveHostServerOnConnect } from '../../ssh/ssh-host-server-on-connect' +import { unservedHostServerMessage } from '../../ssh/ssh-host-unserved-status' const mocks = vi.hoisted(() => { const registry: { current: unknown } = { current: null } @@ -103,6 +105,30 @@ describe('connect-time server decision against the real profile', () => { expect(mocks.deploy).not.toHaveBeenCalled() }) + it('does not remember a noexec home, so a connect after the remount deploys', async () => { + mocks.deploy.mockRejectedValue(new RemoteNodeRuntimeNoexecError('exit 126: Permission denied')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const decision = await resolveHostServerOnConnect( + target(), + hostServerOnConnectDeps(userDataPath) + ) + warn.mockRestore() + expect(decision).toEqual({ + route: 'unserved', + reason: 'orcad_unavailable', + detail: 'runtime_noexec' + }) + // The connect's own error, which the host row shows, names noexec rather than "unsupported". + expect(decision.route === 'unserved' && unservedHostServerMessage(decision)).toMatch( + /noexec.*connect again/ + ) + expect(target().managedServerUnavailable).toBeUndefined() + + mocks.deploy.mockClear() + await resolveHostServerOnConnect(target(), hostServerOnConnectDeps(userDataPath)) + expect(mocks.deploy).toHaveBeenCalledTimes(1) + }) + it('retries a host an older build kept on the relay', async () => { const deps = hostServerOnConnectDeps(userDataPath) store.updateSshTarget(TARGET.id, { diff --git a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts index 60429b37b1eb..c79d5de4eb8a 100644 --- a/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh/ssh-host-server-on-connect-wiring.ts @@ -9,6 +9,7 @@ import { findOrcadMigrationSourceCutoverForTarget } from '../../ssh/orcad-migrat import { abandonOrcadConversion } from '../../ssh/orcad-conversion-abandon' import { retainOrcadMigrationSource } from '../../ssh/orcad-migration-source-retention' import { hasOrcadTemplate } from '../../orcad/orcad-artifact-materializer' +import { isOrcadHostUnavailableFixableOnHost } from '../../orcad/orcad-host-unavailable' import { managedServerUpdateDeps } from '../../ssh/managed-server-update-deps' import { ensureOrcadManagedTunnel } from '../../managed-server/lifecycle/orcad-managed-tunnel' import { verifyOrcadManagedServing } from '../../managed-server/lifecycle/orcad-managed-serving-verify' @@ -66,7 +67,9 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne ? target.managedServerUnavailable.reason : null, recordUnavailable: (target, reason) => { - registry.updateTarget(target.id, { managedServerUnavailable: { reason, appVersion } }) + if (!isOrcadHostUnavailableFixableOnHost(reason)) { + registry.updateTarget(target.id, { managedServerUnavailable: { reason, appVersion } }) + } }, ...managedServerUpdateDeps(userDataPath), isEmptyHost: (target) => { diff --git a/src/main/ipc/ssh/ssh-renderer-broadcast.ts b/src/main/ipc/ssh/ssh-renderer-broadcast.ts index c25733dba743..1caa3951ce49 100644 --- a/src/main/ipc/ssh/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh/ssh-renderer-broadcast.ts @@ -5,7 +5,7 @@ import type { EnrichedDetectedPort, SshConnectionState } from '../../../shared/ssh-types' -import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import { isOwnedSshTargetId } from '../../../shared/execution-host' import { enrichSshDetectedPorts, enrichSshForwardEntries, @@ -31,7 +31,7 @@ export function broadcastSshState( ): void { const target = getSshTargetRegistryStore()?.getTarget(targetId) // Why: owned targets are hidden from clients; broadcasting them leaks internal transport state into persisted reconnect hints. - if (isRuntimeOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) { + if (isOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) { currentRuntime?.invalidateSshWorktreeScanCache?.(targetId) return } diff --git a/src/main/ipc/worktrees-test-runtime-stub.ts b/src/main/ipc/worktrees-test-runtime-stub.ts index ef74ced558b5..fe751b0e4223 100644 --- a/src/main/ipc/worktrees-test-runtime-stub.ts +++ b/src/main/ipc/worktrees-test-runtime-stub.ts @@ -13,6 +13,7 @@ export type WorktreeRuntimeStub = { recordOptimisticReconcileToken: ReturnType reconcileWorktreeBaseStatus: ReturnType clearOptimisticReconcileToken: ReturnType + releaseRemovedWorktreeHostSessions: ReturnType resolveManagedMrBase: ReturnType createTerminal: ReturnType invalidateWorktreeCatalog: ReturnType @@ -43,6 +44,7 @@ export function createWorktreeRuntimeStub(mainWindow?: { recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), clearOptimisticReconcileToken: vi.fn(), + releaseRemovedWorktreeHostSessions: vi.fn(), resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }), createTerminal: vi.fn().mockResolvedValue({ handle: 'term-startup', diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index f1eb0078854b..d3746a2d4a00 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -308,6 +308,7 @@ describe('registerWorktreeHandlers โ€“ Windows path handling', () => { recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), clearOptimisticReconcileToken: vi.fn(), + releaseRemovedWorktreeHostSessions: vi.fn(), closeFileWatchersForRemoval: vi.fn().mockResolvedValue(undefined), acquireFileWatcherRemoval: vi.fn().mockResolvedValue({ finish: vi.fn().mockResolvedValue(undefined) diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts index 1b5767a633ce..a10097152145 100644 --- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -138,7 +138,7 @@ export async function executeWorktreeRemoval( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, removalHostId, args.snapshotPruneBatchId diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index c330c3d217fb..6e0403f4ff7c 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -40,6 +40,7 @@ import { assertWslMoveRepoWritable, isWslMoveSourceHost } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { teardownStops } from '../../../runtime/owned-session-teardown-policy' export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context @@ -113,7 +114,7 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi onPtyStopped: clearProviderPtyState, // Forgetting an orphan still purges its workspace metadata, so retire structured chat // tabs even when no provider child is attached to the workspace. - closeStructuredSessions: true, + closeStructuredSessions: teardownStops('worktree-removal', 'structured-chat'), ...(externalHost ? { includeProviderInventory: false, includeLocalRegistry: false } : {}) }).catch((err) => { console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) @@ -122,7 +123,7 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi runtime.clearOptimisticReconcileToken(args.worktreeId) // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, ownerHost?.id, args.snapshotPruneBatchId diff --git a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts index a6eb9d5de317..e1d4c0cfa418 100644 --- a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts +++ b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts @@ -12,6 +12,7 @@ import type { WorktreeIpcContext } from '../worktree-ipc-context' import { getFolderWorkspaceRootId } from '../folder-workspace-model' import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' +import { teardownStops } from '../../../runtime/owned-session-teardown-policy' export async function removeFolderWorkspace( context: WorktreeIpcContext, @@ -43,7 +44,7 @@ export async function removeFolderWorkspace( onPtyStopped: clearProviderPtyState, // A structured session is on no PTY surface, so the sweeps above leave it attached to a // workspace this removal is about to forget. Closed best-effort, exactly like those PTYs. - closeStructuredSessions: true, + closeStructuredSessions: teardownStops('folder-removal', 'structured-chat'), ...(externalHost ? { includeProviderInventory: false, includeLocalRegistry: false } : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) @@ -51,7 +52,7 @@ export async function removeFolderWorkspace( }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { removeWorktreeMetadataAndTransientState( - store, + { store, runtime, entryPoint: 'folder-removal' }, args.worktreeId, removalHostId, args.snapshotPruneBatchId 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 4fd9c2b9addd..16d40634bcb9 100644 --- a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -271,7 +271,7 @@ async function finishLocalWorktreeRemoval({ await cleanupPushTarget() runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, removalHostId, args.snapshotPruneBatchId @@ -308,7 +308,7 @@ async function finishLocalWorktreeRemoval({ runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, removalHostId, args.snapshotPruneBatchId diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts index 1980f77bf1e5..3d674077d117 100644 --- a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts @@ -90,7 +90,7 @@ export async function removeUnregisteredWorktree( invalidateAuthorizedRootsCache() runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, removalHostId, args.snapshotPruneBatchId @@ -120,7 +120,7 @@ export async function removeUnregisteredWorktree( invalidateAuthorizedRootsCache() runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, args.worktreeId, removalHostId, args.snapshotPruneBatchId diff --git a/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts index 30fd0351728f..8b039aa42cb9 100644 --- a/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts +++ b/src/main/ipc/worktrees/removal/retry-failed-local-worktree-removal.ts @@ -47,7 +47,7 @@ export function retryFailedLocalWorktreeRemoval( purge: ({ worktreeId, repoId }) => { runtime.clearOptimisticReconcileToken(worktreeId) removeWorktreeMetadataAndTransientState( - store, + { store, runtime }, worktreeId, LOCAL_EXECUTION_HOST_ID, args.snapshotPruneBatchId diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index a3b17ce9b87c..b5678cd538de 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -70,7 +70,15 @@ export function resolveWorktreeRemovalOwnerHostId( } export function removeWorktreeMetadataAndTransientState( - store: Store, + { + store, + runtime, + entryPoint = 'worktree-removal' + }: { + store: Store + runtime: Pick + entryPoint?: 'worktree-removal' | 'folder-removal' + }, worktreeId: string, hostId?: ExecutionHostId, snapshotPruneBatchId?: string @@ -98,6 +106,8 @@ export function removeWorktreeMetadataAndTransientState( deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). pruneWorktreePRRefreshAliases(worktreeId) + // Why: the desktop also hosts browser pages for paired clients; the runtime path closes them too. + runtime.releaseRemovedWorktreeHostSessions(worktreeId, entryPoint) } // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. const snapshotDirectory = store.getProfileStorageDirectory() diff --git a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts index e51b23107d3f..65ba235087f5 100644 --- a/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts +++ b/src/main/managed-server/activation/orcad-activation-crash-recovery.test.ts @@ -142,7 +142,10 @@ function expectExactlyTheRecordedSlot(): void { /** Unconfirmed termination never deletes a slot directory or a snapshot. */ function expectNoSlotOrSnapshotRemoved(): void { - const removals = host.commands.filter((command) => /\brm -r?f\b/u.test(command)) + const removals = host.commands + .filter((command) => /\brm -r?f\b/u.test(command)) + // A failed capture's own partial archive was never a snapshot. + .map((command) => command.replace(/rm -f '[^']*\/state\.tar'\.partial/gu, '')) for (const command of removals) { // A file inside a slot (a consumed stop request) may go; the slot directory never does. expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d[^'/]*\/?'/u) diff --git a/src/main/managed-server/activation/orcad-activation-lock.ts b/src/main/managed-server/activation/orcad-activation-lock.ts index 47f63e915002..cf7e58e21029 100644 --- a/src/main/managed-server/activation/orcad-activation-lock.ts +++ b/src/main/managed-server/activation/orcad-activation-lock.ts @@ -272,7 +272,11 @@ async function releaseActivationFence( * Each piece is renamed aside first and kept only if it is ours, else put straight back: a release * that stalls after its token check can then never delete a successor's journal or lock. */ -function posixReleaseFenceCommand(fence: OrcadFence, journal: string, lockRoot: string): string { +export function posixReleaseFenceCommand( + fence: OrcadFence, + journal: string, + lockRoot: string +): string { const lock = quotePosixShell(fence.lockDir) const journalPath = quotePosixShell(journal) const stamp = quotePosixShell(journalFenceStamp(fence.token)) diff --git a/src/main/managed-server/host-state/orcad-state-newest-mtime.ts b/src/main/managed-server/host-state/orcad-state-newest-mtime.ts new file mode 100644 index 000000000000..c9752bfdbd07 --- /dev/null +++ b/src/main/managed-server/host-state/orcad-state-newest-mtime.ts @@ -0,0 +1,40 @@ +/** When the shared profile state was last written, which tells a rollback whether to restore. */ +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { isWindowsRemoteHost, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { orcadWindowsHostOpCommand } from '../install/orcad-remote-windows-node' +import { ORCAD_SNAPSHOT_MEMBERS } from './orcad-state-snapshot-members' + +/** + * Has the shared store been written since `activatedAt`? + * + * Prints the newest mtime (epoch seconds) across the snapshot members, or `UNKNOWN`. The + * caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes, + * assume writes". + */ +export function newestStateMtimeCommand( + host: RemoteHostPlatform, + userDataDir: string, + baseDir: string +): string { + if (isWindowsRemoteHost(host)) { + return orcadWindowsHostOpCommand(host, baseDir, 'state-newest-mtime', [userDataDir]) + } + const root = quotePosixShell(userDataDir) + const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${quotePosixShell(member)}`).join( + ' ' + ) + return [ + `newest=$(find ${paths} -type f -exec stat -c %Y {} + 2>/dev/null ||`, + `find ${paths} -type f -exec stat -f %m {} + 2>/dev/null);`, + 'if [ -z "$newest" ]; then echo UNKNOWN; else', + `echo "$newest" | sort -n | tail -1; fi` + ].join(' ') +} + +export function parseNewestStateMtimeSeconds(output: string): number | null { + const value = output.trim().split('\n').pop()?.trim() + if (!value || !/^\d+$/.test(value)) { + return null + } + return Number.parseInt(value, 10) +} diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-members.ts b/src/main/managed-server/host-state/orcad-state-snapshot-members.ts index b462b1f4aaa5..e4f12921769a 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot-members.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot-members.ts @@ -37,3 +37,6 @@ export const ORCAD_STATE_MUTATION_DEADLINE = 'STATE_MUTATION_DEADLINE' /** Windows keeps the snapshot as a directory copy, where POSIX keeps `state.tar`. */ export const ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME = 'state' + +/** Prefixes the line a failed POSIX capture prints, ahead of `FAILED`, saying why. */ +export const ORCAD_SNAPSHOT_FAILURE = 'SNAPSHOT_FAILURE' diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts b/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts index 36898d2cec4d..565804db7894 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot-shell.test.ts @@ -33,8 +33,12 @@ const posix = getRemoteHostPlatform('linux-x64') // Linux hosts often run dash as /bin/sh; its builtins differ from bash's (`kill -- -pgid`). const SHELL = process.env.ORCA_TEST_POSIX_SHELL ?? '/bin/sh' -async function sh(command: string): Promise { - const result = await runProcess({ program: SHELL, args: ['-c', command] }) +async function sh(command: string, env?: NodeJS.ProcessEnv): Promise { + const result = await runProcess({ + program: SHELL, + args: ['-c', command], + ...(env ? { env: { ...process.env, ...env } } : {}) + }) return result.stdout } @@ -138,6 +142,28 @@ describe.skipIf(process.platform === 'win32')('snapshot commands on a real shell expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') }) + it('fails a restore whose member cannot move in, then reruns cleanly from the archive', async () => { + await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot, remoteBase)) + writeFileSync(join(root, 'profiles', 'p.json'), 'migrated') + // A `mv` that refuses only the profiles member, so the restore stops part way through. + const bin = join(base, 'bin') + mkdirSync(bin) + const mvPath = (await runProcess({ program: SHELL, args: ['-c', 'command -v mv'] })).stdout + writeFileSync( + join(bin, 'mv'), + `#!/bin/sh\ncase "$1" in *profiles) exit 1;; esac\nexec ${mvPath.trim()} "$@"\n`, + { mode: 0o755 } + ) + const restore = restoreOrcadStateSnapshotCommand(posix, root, snapshot, remoteBase) + + const failed = await sh(restore, { PATH: `${bin}:${process.env.PATH ?? ''}` }) + expect(parseOrcadSnapshotRestore(failed)).toBe('failed') + expect(existsSync(join(root, '.orcad-state-restore-stage', 'profiles'))).toBe(true) + + expect(parseOrcadSnapshotRestore(await sh(restore))).toBe('restored') + expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') + }) + it('clears only the snapshot members for a root that started empty', async () => { expect( parseOrcadSnapshotRestore( diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts b/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts new file mode 100644 index 000000000000..89a4f3771339 --- /dev/null +++ b/src/main/managed-server/host-state/orcad-state-snapshot-space.test.ts @@ -0,0 +1,152 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from '@orca/process-host' +import { captureOrcadStateSnapshotCommand, parseOrcadSnapshotCapture } from './orcad-state-snapshot' +import { + orcadSnapshotFailureReason, + orcadSnapshotShortfallKb, + orcadSnapshotShortfallMessage, + orcadSnapshotSpaceCommand, + parseOrcadSnapshotSpace +} from './orcad-state-snapshot-space' +import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' + +const posix = getRemoteHostPlatform('linux-x64') +const SHELL = process.env.ORCA_TEST_POSIX_SHELL ?? '/bin/sh' +// Root reads a mode-000 file anyway, so the unreadable-state case only exists for other users. +const ROOT = process.getuid?.() === 0 + +async function sh(command: string, path?: string): Promise { + const env = path ? { ...process.env, PATH: `${path}:${process.env.PATH ?? ''}` } : process.env + return (await runProcess({ program: SHELL, args: ['-c', command], env })).stdout +} + +describe.skipIf(process.platform === 'win32')('snapshot room on a real shell (#30491)', () => { + let base: string + let root: string + let snapshots: string + let remoteBase: string + + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), 'orcad-snapshot-space-')) + root = join(base, 'root') + remoteBase = join(base, '.orca-remote') + snapshots = join(remoteBase, 'orcad-state-snapshots') + mkdirSync(join(root, 'profiles', 'p1'), { recursive: true }) + writeFileSync(join(root, 'profiles', 'p1', 'profile-state.db'), 'x'.repeat(64 * 1024)) + for (const name of ['pre-0.1.0+aa01-1', 'pre-0.1.0+bb02-2', 'rollback-rescue-0.1.0+bb02-3']) { + mkdirSync(join(snapshots, name), { recursive: true }) + writeFileSync(join(snapshots, name, 'state.tar'), 'y'.repeat(64 * 1024)) + } + }) + + afterEach(() => { + chmodSync(join(root, 'profiles', 'p1'), 0o700) + rmSync(base, { recursive: true, force: true }) + }) + + it.skipIf(ROOT)('removes its partial archive and says why when the copy fails', async () => { + writeFileSync(join(root, 'profiles', 'p1', 'locked'), 'secret') + chmodSync(join(root, 'profiles', 'p1', 'locked'), 0o000) + const snapshot = join(snapshots, 'pre-0.2.0+cc03-4') + + const output = await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot, remoteBase)) + + expect(parseOrcadSnapshotCapture(output)).toBe('failed') + expect(orcadSnapshotFailureReason(output)).toMatch(/Permission denied/u) + // A leftover partial is a full copy of the state, and it fills the disk the retry needs. + expect(existsSync(snapshot)).toBe(false) + }) + + it('prunes earlier captures but keeps the named ones and every rollback rescue', async () => { + const snapshot = join(snapshots, 'pre-0.2.0+cc03-4') + const output = await sh( + captureOrcadStateSnapshotCommand(posix, root, snapshot, remoteBase, { + snapshotsRoot: snapshots, + keep: ['pre-0.1.0+bb02-2', 'pre-0.2.0+cc03-4'] + }) + ) + + expect(parseOrcadSnapshotCapture(output)).toBe('captured') + expect(readdirSync(snapshots).sort()).toEqual([ + 'pre-0.1.0+bb02-2', + 'pre-0.2.0+cc03-4', + 'rollback-rescue-0.1.0+bb02-3' + ]) + expect(existsSync(join(snapshot, 'state.tar'))).toBe(true) + }) + + it('counts what a prune would reclaim as room, and refuses when even that is short', async () => { + const bin = join(base, 'bin') + mkdirSync(bin) + writeFileSync( + join(bin, 'df'), + "#!/bin/sh\nprintf 'Filesystem 1024-blocks Used Available Capacity Mounted on\\nfiler:/home 100 100 0 100%% /home\\n'\n" + ) + chmodSync(join(bin, 'df'), 0o755) + const measure = async (keep: string[]) => + parseOrcadSnapshotSpace( + await sh(orcadSnapshotSpaceCommand(posix, root, snapshots, remoteBase, keep) ?? '', bin) + ) + + const keepAll = await measure(['pre-0.1.0+aa01-1', 'pre-0.1.0+bb02-2']) + const keepOne = await measure(['pre-0.1.0+bb02-2']) + expect(keepAll?.availableKb).toBe(0) + expect(keepAll?.neededKb).toBeGreaterThanOrEqual(64) + expect(keepOne?.availableKb).toBeGreaterThanOrEqual(64) + // Measuring never removes anything; only the capture prunes. + expect(readdirSync(snapshots)).toHaveLength(3) + + const shortfall = orcadSnapshotShortfallKb(keepAll) + expect(shortfall).toBeGreaterThan(0) + expect( + orcadSnapshotShortfallMessage( + keepAll ?? { neededKb: 0, availableKb: 0, reclaimKb: 0 }, + shortfall ?? 0, + { + userDataDir: '/home/work/.orca', + baseDir: '/home/work/.orca-remote', + fullVersion: '0.1.0+77dfa08c40aa', + active: '0.1.0+aa01' + } + ) + ).toMatch( + /has 0 KB free, and.*left the current server \(0\.1\.0\+aa01\) in place.*Nothing was stopped/u + ) + }) + + it('answers UNMEASURED, which reads as unknown, when a scan overruns its deadline', async () => { + const bin = join(base, 'bin') + mkdirSync(bin) + // Lets the syntax probe and the members' scan through; times out only the old snapshots'. + writeFileSync( + join(bin, 'timeout'), + '#!/bin/sh\nshift\ncase "$*" in *orcad-state-snapshots*) exit 124;; esac\nexec "$@"\n' + ) + chmodSync(join(bin, 'timeout'), 0o755) + + const output = await sh( + orcadSnapshotSpaceCommand(posix, root, snapshots, remoteBase, []) ?? '', + bin + ) + + expect(output.trim()).toBe('SNAPSHOT_SPACE UNMEASURED') + expect(parseOrcadSnapshotSpace(output)).toBeNull() + }) + + it('reads no room answer from a host that printed none', () => { + expect(parseOrcadSnapshotSpace('')).toBeNull() + expect(orcadSnapshotShortfallKb(null)).toBeNull() + expect(orcadSnapshotShortfallKb({ neededKb: 0, availableKb: 0, reclaimKb: 0 })).toBeNull() + }) +}) diff --git a/src/main/managed-server/host-state/orcad-state-snapshot-space.ts b/src/main/managed-server/host-state/orcad-state-snapshot-space.ts new file mode 100644 index 000000000000..dd360c7c0932 --- /dev/null +++ b/src/main/managed-server/host-state/orcad-state-snapshot-space.ts @@ -0,0 +1,133 @@ +/** + * Room for the pre-activation snapshot (#30491). + * + * Only the snapshot the activation record names can ever be restored, yet every update used to + * leave its own full copy of the state behind, and a failed capture its partial archive. On a + * small or quota-limited home that grows until no update can snapshot at all. So the capture + * prunes every other `pre-*` capture first, and the activation checks for room before it stops + * the running server: a host that cannot fit the snapshot keeps that server. + */ +import { quotePosixShell } from '../../../shared/shell-argument-quoting' +import { isWindowsRemoteHost, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { ORCAD_SNAPSHOT_FAILURE, ORCAD_SNAPSHOT_MEMBERS } from './orcad-state-snapshot-members' +import { dfAvailableKb, formatKb } from './remote-filesystem-space' + +const SPACE_MARKER = 'SNAPSHOT_SPACE' +const CAPTURE_PREFIX = 'pre-' +// tar's headers and 10 KB records, plus room for the state to grow before the server stops. +const SPACE_FACTOR = 1.05 +const SPACE_SLACK_KB = 1024 +const SPACE_SCAN_DEADLINE_SECONDS = 12 + +/** + * Removes every `pre-*` capture under `snapshotsRoot` except `keep`. Never a rollback rescue: + * that holds the newer build's state, which nothing else does. + */ +export function pruneOrcadSnapshotsScript(snapshotsRoot: string, keep: readonly string[]): string { + return [ + `for old in ${quotePosixShell(snapshotsRoot)}/${CAPTURE_PREFIX}*; do [ -d "$old" ] || continue;`, + `case "\${old##*/}" in (${prunePatterns(keep)}) ;; (*) rm -rf "$old";; esac; done;` + ].join(' ') +} + +function prunePatterns(keep: readonly string[]): string { + // Quoted, so `+` and `.` in a version match only themselves; `''` keeps nothing. Callers wrap + // these in `(...)`: bash 3.2 misparses a bare `pat)` inside `$(...)`. + return keep.length > 0 ? keep.map(quotePosixShell).join('|') : "''" +} + +/** + * Prints the KB the members occupy, the KB a prune would reclaim, and the `df -Pk` line for + * `baseDir`; `UNMEASURED` when a scan overran its deadline. Read-only, so it can run before + * anything stops. Null on Windows (not measured). + */ +export function orcadSnapshotSpaceCommand( + host: RemoteHostPlatform, + userDataDir: string, + snapshotsRoot: string, + baseDir: string, + keep: readonly string[] +): string | null { + if (isWindowsRemoteHost(host)) { + return null + } + const root = quotePosixShell(userDataDir) + const members = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${quotePosixShell(member)}`) + const sum = `awk '{ total += $1 } END { print total + 0 }'` + return [ + // Both scans together stay inside the transport's 30 s exec timeout; 124+ is timeout's kill. + // BusyBox before 1.30 spells it `timeout -t SECS`. + `deadline=; if timeout 1 true 2>/dev/null; then deadline="timeout ${SPACE_SCAN_DEADLINE_SECONDS}";`, + `elif timeout -t 1 true 2>/dev/null; then deadline="timeout -t ${SPACE_SCAN_DEADLINE_SECONDS}"; fi;`, + `set --; for old in ${quotePosixShell(snapshotsRoot)}/${CAPTURE_PREFIX}*; do [ -d "$old" ] || continue;`, + `case "\${old##*/}" in (${prunePatterns(keep)}) ;; (*) set -- "$@" "$old";; esac; done;`, + `need=$($deadline du -sk ${members.join(' ')} 2>/dev/null); need_status=$?;`, + 'reclaim=; reclaim_status=0; if [ $# -gt 0 ]; then reclaim=$($deadline du -sk "$@" 2>/dev/null); reclaim_status=$?; fi;', + `if [ "$need_status" -ge 124 ] || [ "$reclaim_status" -ge 124 ]; then echo "${SPACE_MARKER} UNMEASURED"; else`, + `echo "${SPACE_MARKER} $(printf '%s\\n' "$need" | ${sum}) $(printf '%s\\n' "$reclaim" | ${sum})`, + `$(df -Pk ${quotePosixShell(baseDir)} 2>/dev/null | tail -n 1)"; fi` + ].join(' ') +} + +/** `availableKb` counts `reclaimKb`: room the capture's own prune frees before it writes. */ +export type OrcadSnapshotSpace = { neededKb: number; availableKb: number; reclaimKb: number } + +/** Null when the answer is missing or unreadable: the capture itself still decides. */ +export function parseOrcadSnapshotSpace(output: string): OrcadSnapshotSpace | null { + const line = output + .split('\n') + .toReversed() + .find((candidate) => candidate.startsWith(`${SPACE_MARKER} `)) + const [, need, reclaim, ...df] = (line ?? '').trim().split(/\s+/u) + const available = dfAvailableKb(df.join(' ')) + if (!need || !reclaim || !/^\d+$/u.test(need) || !/^\d+$/u.test(reclaim) || available === null) { + return null + } + return { + neededKb: Number(need), + availableKb: available + Number(reclaim), + reclaimKb: Number(reclaim) + } +} + +/** The KB the host is short of a snapshot, or null when it fits (or there is nothing to keep). */ +export function orcadSnapshotShortfallKb(space: OrcadSnapshotSpace | null): number | null { + if (!space || space.neededKb === 0) { + return null + } + const required = Math.ceil(space.neededKb * SPACE_FACTOR) + SPACE_SLACK_KB + return space.availableKb >= required ? null : required - space.availableKb +} + +export function orcadSnapshotShortfallMessage( + space: OrcadSnapshotSpace, + shortfallKb: number, + where: { userDataDir: string; baseDir: string; fullVersion: string; active: string | null } +): string { + // Says only what the update did: the record names the server, not whether it is up. + const keeps = where.active + ? `The update left the current server (${where.active}) in place.` + : 'Orca cannot start its server on this host until then.' + const free = formatKb(space.availableKb - space.reclaimKb) + const reclaim = + space.reclaimKb > 0 + ? ` (plus ${formatKb(space.reclaimKb)} Orca would reclaim from earlier snapshots)` + : '' + return ( + `Not enough disk space to snapshot ${where.userDataDir} before activating ` + + `${where.fullVersion}: the filesystem holding ${where.baseDir} has ` + + `${free} free${reclaim}, and the snapshot needs about ` + + `${formatKb(space.neededKb)}. Free at least ${formatKb(shortfallKb)} there (or raise the ` + + `quota), then reconnect. ${keeps} Nothing was stopped.` + ) +} + +/** The reason a POSIX capture printed before `FAILED`, if it printed one. */ +export function orcadSnapshotFailureReason(output: string): string | null { + const line = output + .split('\n') + .toReversed() + .find((candidate) => candidate.startsWith(`${ORCAD_SNAPSHOT_FAILURE} `)) + const reason = line?.slice(ORCAD_SNAPSHOT_FAILURE.length).trim() + return reason ? reason : null +} diff --git a/src/main/managed-server/host-state/orcad-state-snapshot.test.ts b/src/main/managed-server/host-state/orcad-state-snapshot.test.ts index 038722a355e5..05f820d0fb59 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot.test.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot.test.ts @@ -4,9 +4,7 @@ import { captureOrcadStateSnapshotCommand, clearOrcadStateSnapshotMembersCommand, compareOrcadStateSnapshotCommand, - newestStateMtimeCommand, orcadSnapshotDirName, - parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, parseOrcadSnapshotPresence, parseOrcadSnapshotRestore, @@ -14,6 +12,7 @@ import { restoreOrcadStateSnapshotCommand, ORCAD_STATE_MUTATION_DEADLINE_SECONDS } from './orcad-state-snapshot' +import { newestStateMtimeCommand, parseNewestStateMtimeSeconds } from './orcad-state-newest-mtime' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS } from './orcad-state-snapshot-members' @@ -23,6 +22,11 @@ const ROOT = '/home/u/.orca' const SNAP = '/home/u/.orca-remote/orcad-state-snapshots/pre-0.2.0+bb01-1000' const BASE = '/home/u/.orca-remote' +/** Every `for m in ;` list a command walks. */ +function memberLists(command: string): string[] { + return [...command.matchAll(/for m in ([^;]+);/g)].map((match) => match[1]) +} + /** The work inside the lock-and-deadline wrapper, with its quoting undone. */ function innerScript(command: string): string { return command.replaceAll(`'\\''`, `'`) @@ -30,20 +34,23 @@ function innerScript(command: string): string { describe('capturing the pre-activation snapshot', () => { it('captures the profile state a rollback needs', () => { - const command = captureOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE) - for (const member of ORCAD_SNAPSHOT_MEMBERS) { - expect(command).toContain(`'${member}'`) - } + expect(memberLists(captureOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE))).toEqual([ + ORCAD_SNAPSHOT_MEMBERS.join(' ') + ]) }) // The live daemon owns /daemon and outlives every restart. Restoring a stale copy of // its socket, PID record and token would break the fence that keeps its terminals adoptable. - it.each(ORCAD_SNAPSHOT_EXCLUDED)('never captures %s', (excluded) => { - expect(captureOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE)).not.toContain(`'${excluded}'`) - }) - - it.each(ORCAD_SNAPSHOT_EXCLUDED)('never removes or restores over %s', (excluded) => { - expect(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE)).not.toContain(`'${excluded}'`) + it.each(ORCAD_SNAPSHOT_EXCLUDED)('never captures, removes or restores over %s', (excluded) => { + const commands = [ + captureOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE), + restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE), + clearOrcadStateSnapshotMembersCommand(posix, ROOT, BASE), + compareOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE) + ] + for (const list of commands.flatMap(memberLists)) { + expect(list.split(' ')).not.toContain(excluded) + } }) it('writes the archive under a temp name and renames, so a killed deploy leaves no torn tar', () => { @@ -78,7 +85,10 @@ describe('the host-side guard around every state mutation', () => { ['clear', () => clearOrcadStateSnapshotMembersCommand(posix, ROOT, BASE)] ])('%s runs under the host lock and a host-enforced deadline', (_label, build) => { const command = build() - expect(command).toContain(`timeout -s KILL ${ORCAD_STATE_MUTATION_DEADLINE_SECONDS} sh -c`) + expect(command).toContain( + `orca_deadline="timeout -s KILL ${ORCAD_STATE_MUTATION_DEADLINE_SECONDS}"` + ) + expect(command).toContain('orca_state_group $orca_deadline sh -c') expect(innerScript(command)).toContain(`lock='${BASE}/orcad-state-mutation.lock'`) expect(command).toContain('echo STATE_MUTATION_DEADLINE') }) @@ -87,9 +97,9 @@ describe('the host-side guard around every state mutation', () => { describe('restoring the snapshot', () => { it('proves the archive extracts before clearing the live members', () => { const command = innerScript(restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP, BASE)) - const extract = command.indexOf(`tar -C '${ROOT}/.orcad-state-restore-stage'`) + const extract = command.indexOf('tar -C "$stage" -xf "$archive"') expect(extract).toBeGreaterThan(-1) - expect(extract).toBeLessThan(command.indexOf(`rm -rf '${ROOT}'/'profiles'`)) + expect(extract).toBeLessThan(command.indexOf('rm -rf "$root/$m"')) }) it('reports a missing archive instead of extracting nothing and claiming success', () => { diff --git a/src/main/managed-server/host-state/orcad-state-snapshot.ts b/src/main/managed-server/host-state/orcad-state-snapshot.ts index a8335114d5fc..f7ec816e289f 100644 --- a/src/main/managed-server/host-state/orcad-state-snapshot.ts +++ b/src/main/managed-server/host-state/orcad-state-snapshot.ts @@ -30,7 +30,9 @@ import { type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' import type { OrcadWindowsHostStateOp } from './orcad-windows-host-state-ops' +import { pruneOrcadSnapshotsScript } from './orcad-state-snapshot-space' import { + ORCAD_SNAPSHOT_FAILURE, ORCAD_SNAPSHOT_MEMBERS, ORCAD_STATE_MUTATION_BUSY, ORCAD_STATE_MUTATION_DEADLINE, @@ -45,15 +47,18 @@ import { } from './orcad-state-mutation-owner-record' /** - * The member names go into the command unquoted (see `captureOrcadStateSnapshotCommand`), so - * they must be inert. They are compile-time constants; this catches the edit that adds one - * with a space or a metacharacter in it. + * The member names go into the commands' `for` lists unquoted, so they must be inert. They are + * compile-time constants; this catches the edit that adds one with a space or a metacharacter. + * Why one loop over the names rather than a test per member: every path is then sent once, which + * keeps each op under the gateway exec limit (#30491). */ -function assertPlainMemberName(member: string): string { - if (!/^[A-Za-z0-9._-]+$/.test(member)) { - throw new Error(`Unsafe orcad snapshot member name: ${JSON.stringify(member)}`) +function plainMemberWords(): string { + for (const member of ORCAD_SNAPSHOT_MEMBERS) { + if (!/^[A-Za-z0-9._-]+$/.test(member)) { + throw new Error(`Unsafe orcad snapshot member name: ${JSON.stringify(member)}`) + } } - return member + return ORCAD_SNAPSHOT_MEMBERS.join(' ') } /** The Windows host-script op, or null on POSIX; `baseDir` is `~/.orca-remote`. */ @@ -129,15 +134,13 @@ export function serializedStateMutationCommand( `elif command -v perl >/dev/null 2>&1; then ORCA_STATE_MUTATION_GROUP=1 perl -e ${quotePosixShell('setpgrp(0, 0); exec { $ARGV[0] } @ARGV or exit 127')} "$@";`, 'else "$@"; fi; };', // Why timeout inside the group: KILL then reaches tar and rm, not only the shell. - `if command -v timeout >/dev/null 2>&1; then orca_state_group timeout -s KILL ${ORCAD_STATE_MUTATION_DEADLINE_SECONDS} ${run}; else orca_state_group ${run}; fi;`, + // The deadline is a word list, so the script is sent once, not once per branch. + `orca_deadline=; command -v timeout >/dev/null 2>&1 && orca_deadline="timeout -s KILL ${ORCAD_STATE_MUTATION_DEADLINE_SECONDS}";`, + `orca_state_group $orca_deadline ${run};`, `status=$?; ${owned ? `[ "$status" -eq ${ORCAD_FENCE_LOST_EXIT} ] && exit ${ORCAD_FENCE_LOST_EXIT}; ` : ''}if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then echo ${ORCAD_STATE_MUTATION_DEADLINE}; fi` ].join(' ') } -function noSymlinkedStateCommand(path: string): string { - return `links=$(find ${path} -type l -print) && [ -z "$links" ]` -} - export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): string { // Why the version and the timestamp: two activations of one version (a re-deploy after a // rejected activation) must not overwrite each other's snapshot. @@ -155,46 +158,40 @@ export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: numbe * Prints `CAPTURED`, or `EMPTY` when the data root holds none of the members โ€” a first-ever * deployment, where there is no state to lose and therefore no snapshot to take. `EMPTY` is * reported rather than fabricating an empty archive, because a rollback that "restored" an - * empty archive would wipe a root that had filled up in between. + * empty archive would wipe a root that had filled up in between. A failure prints its reason + * and removes its partial archive, which would otherwise fill the disk a retry needs (#30491). */ export function captureOrcadStateSnapshotCommand( host: RemoteHostPlatform, userDataDir: string, snapshotDir: string, - baseDir: string + baseDir: string, + prune?: { snapshotsRoot: string; keep: readonly string[] } ): string { const windows = windowsStateCommand(host, baseDir, 'snapshot-capture', [userDataDir, snapshotDir]) if (windows) { return windows } - const root = quotePosixShell(userDataDir) - const dir = quotePosixShell(snapshotDir) - const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) - const memberTests = ORCAD_SNAPSHOT_MEMBERS.map( - // Why the accumulated name is NOT quoted: `$members` is re-split by the shell before it - // reaches tar, so a quoted name arrives as a literal `'profiles'` that tar cannot stat. - // `assertPlainMemberName` is what makes leaving them bare safe. - (member) => { - const path = `${root}/${quotePosixShell(member)}` - return ( - `if [ -e ${path} ] || [ -L ${path} ]; then ` + - `${noSymlinkedStateCommand(path)} || { echo FAILED; exit 0; }; ` + - `members="$members ${assertPlainMemberName(member)}"; fi;` - ) - } - ).join(' ') return serializedStateMutationCommand( baseDir, [ - `members=;`, - memberTests, - 'if [ -z "$members" ]; then echo EMPTY; else', + `root=${quotePosixShell(userDataDir)}; dir=${quotePosixShell(snapshotDir)};`, + `archive="$dir"/state.tar;`, + `snapshot_fail() { printf '${ORCAD_SNAPSHOT_FAILURE} %s\\n' "$(printf %s "$1" | tr '\\n' ' ' | cut -c 1-400)"; echo FAILED; exit 0; };`, + prune ? pruneOrcadSnapshotsScript(prune.snapshotsRoot, prune.keep) : '', + // Why `$members` is re-split bare: a quoted name would reach tar as one literal word. + `members=; for m in ${plainMemberWords()}; do`, + 'if [ -e "$root/$m" ] || [ -L "$root/$m" ]; then', + 'links=$(find "$root/$m" -type l -print 2>&1) && [ -z "$links" ] ||', + 'snapshot_fail "$m holds a symbolic link or an entry Orca cannot read: $links";', + 'members="$members $m"; fi; done;', // Umask first so the snapshot dir, not just the archive, is owner-only. - `umask 077 && mkdir -p ${dir} &&`, + 'if [ -z "$members" ]; then echo EMPTY; else umask 077;', // Why a temp name then mv: a deploy killed mid-tar must not leave a truncated archive // that a later rollback would happily restore. - `tar -C ${root} -cf ${archive}.partial $members && mv ${archive}.partial ${archive} &&`, - 'echo CAPTURED; fi' + 'if err=$( { mkdir -p "$dir" && tar -C "$root" -cf "$archive.partial" $members &&', + 'mv "$archive.partial" "$archive"; } 2>&1 ); then echo CAPTURED; else', + 'rm -f "$archive.partial"; rmdir "$dir" 2>/dev/null; snapshot_fail "$err"; fi; fi' ].join(' ') ) } @@ -266,31 +263,26 @@ export function restoreOrcadStateSnapshotCommand( if (windows) { return windows } - const root = quotePosixShell(userDataDir) - const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) - const stage = quotePosixShell( - joinRemotePath(host, userDataDir, ORCAD_STATE_RESTORE_STAGE_DIRNAME) - ) - const removals = removeMembersCommand(root) - const replacements = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = quotePosixShell(member) - return `if [ -e ${stage}/${name} ]; then mv ${stage}/${name} ${root}/${name}; fi` - }).join(' && ') - const stagedMemberChecks = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `[ -e ${stage}/${quotePosixShell(member)} ]` - ).join(' || ') + const members = plainMemberWords() return serializedStateMutationCommand( baseDir, [ - `test -f ${archive} || { echo MISSING; exit 0; };`, + `root=${quotePosixShell(userDataDir)};`, + `archive=${quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar'))};`, + `stage="$root"/${ORCAD_STATE_RESTORE_STAGE_DIRNAME};`, + 'test -f "$archive" || { echo MISSING; exit 0; };', 'umask 077;', - `test -d ${root} || mkdir -p ${root};`, + 'test -d "$root" || mkdir -p "$root";', // Re-extracting from the intact archive makes an interrupted restore safe to rerun. - `rm -rf ${stage}; mkdir -p ${stage} || { echo FAILED; exit 0; };`, + 'rm -rf "$stage"; mkdir -p "$stage" || { echo FAILED; exit 0; };', // Extraction proves every archived byte is readable before live state is removed. - `tar -C ${stage} -xf ${archive} 2>/dev/null || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `${stagedMemberChecks} || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `if ${removals} && ${replacements}; then rm -rf ${stage}; echo RESTORED; else echo FAILED; fi` + 'tar -C "$stage" -xf "$archive" 2>/dev/null || { rm -rf "$stage"; echo FAILED; exit 0; };', + `ok=; for m in ${members}; do [ -e "$stage/$m" ] && ok=1; done;`, + '[ -n "$ok" ] || { rm -rf "$stage"; echo FAILED; exit 0; };', + removeMembersCommand(members), + `[ -n "$ok" ] && for m in ${members}; do`, + '[ ! -e "$stage/$m" ] || mv "$stage/$m" "$root/$m" || { ok=; break; }; done;', + 'if [ -n "$ok" ]; then rm -rf "$stage"; echo RESTORED; else echo FAILED; fi' ].join(' ') ) } @@ -305,17 +297,19 @@ export function clearOrcadStateSnapshotMembersCommand( if (windows) { return windows } - const root = quotePosixShell(userDataDir) return serializedStateMutationCommand( baseDir, - `test -d ${root} || mkdir -p ${root}; if ${removeMembersCommand(root)}; then echo RESTORED; else echo FAILED; fi` + [ + `root=${quotePosixShell(userDataDir)}; test -d "$root" || mkdir -p "$root"; ok=1;`, + removeMembersCommand(plainMemberWords()), + 'if [ -n "$ok" ]; then echo RESTORED; else echo FAILED; fi' + ].join(' ') ) } -function removeMembersCommand(root: string): string { - return ORCAD_SNAPSHOT_MEMBERS.map((member) => `rm -rf ${root}/${quotePosixShell(member)}`).join( - ' && ' - ) +/** Clears `ok` and stops at the first member it cannot remove, as an `&&` chain did. */ +function removeMembersCommand(members: string): string { + return `[ -n "$ok" ] && for m in ${members}; do rm -rf "$root/$m" || { ok=; break; }; done;` } export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' @@ -339,27 +333,21 @@ export function compareOrcadStateSnapshotCommand( if (windows) { return windows } - const root = quotePosixShell(userDataDir) - const dir = quotePosixShell(snapshotDir) - const archive = quotePosixShell(joinRemotePath(host, snapshotDir, 'state.tar')) - const comparisons = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = quotePosixShell(member) - return [ - `if [ -e ${root}/${name} ] || [ -L ${root}/${name} ]; then`, - `${noSymlinkedStateCommand(`${root}/${name}`)} || { echo UNKNOWN; exit 0; };`, - `diff -r ${root}/${name} "$comparison"/${name} >/dev/null 2>&1 || verdict=CHANGED;`, - `elif [ -e "$comparison"/${name} ] || [ -L "$comparison"/${name} ]; then verdict=CHANGED; fi;` - ].join(' ') - }).join(' ') + const unknown = '{ echo UNKNOWN; exit 0; };' return [ - `test -d ${root} && test -r ${root} && test -x ${root} || { echo UNKNOWN; exit 0; };`, - `test -f ${archive} || { echo UNKNOWN; exit 0; };`, - `comparison=$(mktemp -d ${dir}/compare.XXXXXX) || { echo UNKNOWN; exit 0; };`, + `root=${quotePosixShell(userDataDir)}; dir=${quotePosixShell(snapshotDir)};`, + `archive="$dir"/state.tar;`, + `test -d "$root" && test -r "$root" && test -x "$root" || ${unknown}`, + `test -f "$archive" || ${unknown}`, + `comparison=$(mktemp -d "$dir"/compare.XXXXXX) || ${unknown}`, `trap 'rm -rf "$comparison"' EXIT HUP INT TERM;`, - `tar -C "$comparison" -xf ${archive} || { echo UNKNOWN; exit 0; };`, - `${noSymlinkedStateCommand('"$comparison"')} || { echo UNKNOWN; exit 0; };`, - 'verdict=UNCHANGED;', - comparisons, + `tar -C "$comparison" -xf "$archive" || ${unknown}`, + `links=$(find "$comparison" -type l -print) && [ -z "$links" ] || ${unknown}`, + `verdict=UNCHANGED; for m in ${plainMemberWords()}; do`, + 'if [ -e "$root/$m" ] || [ -L "$root/$m" ]; then', + `links=$(find "$root/$m" -type l -print) && [ -z "$links" ] || ${unknown}`, + 'diff -r "$root/$m" "$comparison/$m" >/dev/null 2>&1 || verdict=CHANGED;', + 'elif [ -e "$comparison/$m" ] || [ -L "$comparison/$m" ]; then verdict=CHANGED; fi; done;', 'echo "$verdict"' ].join(' ') } @@ -367,39 +355,3 @@ export function compareOrcadStateSnapshotCommand( export function orcadSnapshotIsUnchanged(output: string): boolean { return lastVerdict(output, ['UNCHANGED', 'CHANGED', 'UNKNOWN']) === 'UNCHANGED' } - -/** - * Has the shared store been written since `activatedAt`? - * - * Prints the newest mtime (epoch seconds) across the snapshot members, or `UNKNOWN`. The - * caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes, - * assume writes". - */ -export function newestStateMtimeCommand( - host: RemoteHostPlatform, - userDataDir: string, - baseDir: string -): string { - const windows = windowsStateCommand(host, baseDir, 'state-newest-mtime', [userDataDir]) - if (windows) { - return windows - } - const root = quotePosixShell(userDataDir) - const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${quotePosixShell(member)}`).join( - ' ' - ) - return [ - `newest=$(find ${paths} -type f -exec stat -c %Y {} + 2>/dev/null ||`, - `find ${paths} -type f -exec stat -f %m {} + 2>/dev/null);`, - 'if [ -z "$newest" ]; then echo UNKNOWN; else', - `echo "$newest" | sort -n | tail -1; fi` - ].join(' ') -} - -export function parseNewestStateMtimeSeconds(output: string): number | null { - const value = output.trim().split('\n').pop()?.trim() - if (!value || !/^\d+$/.test(value)) { - return null - } - return Number.parseInt(value, 10) -} diff --git a/src/main/managed-server/host-state/remote-filesystem-space.ts b/src/main/managed-server/host-state/remote-filesystem-space.ts index d9c5392dbeac..429c7000be5b 100644 --- a/src/main/managed-server/host-state/remote-filesystem-space.ts +++ b/src/main/managed-server/host-state/remote-filesystem-space.ts @@ -5,3 +5,11 @@ export function dfAvailableKb(line: string | undefined): number | null { const available = line?.trim().split(/\s+/u)[3] return available && /^\d+$/u.test(available) ? Number(available) : null } + +export function formatKb(kb: number): string { + if (kb < 1024) { + return `${kb} KB` + } + const mb = kb / 1024 + return mb < 1024 ? `${mb.toFixed(1)} MB` : `${(mb / 1024).toFixed(1)} GB` +} diff --git a/src/main/managed-server/install/orcad-remote-install.ts b/src/main/managed-server/install/orcad-remote-install.ts index 0316086b9e60..0463cc35067b 100644 --- a/src/main/managed-server/install/orcad-remote-install.ts +++ b/src/main/managed-server/install/orcad-remote-install.ts @@ -125,7 +125,7 @@ export async function installOrcadBundle( } } -function executablePermissionsCommand( +export function executablePermissionsCommand( host: RemoteHostPlatform, directory: string, target: NodeRuntimeTarget diff --git a/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts b/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts new file mode 100644 index 000000000000..1302b5767bfd --- /dev/null +++ b/src/main/managed-server/install/orcad-remote-node-runtime-noexec.test.ts @@ -0,0 +1,156 @@ +import type * as ExecCommandModule from '../../ssh/remote-exec/remote-exec-command' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from '@orca/process-host' +import type { OrcadHostTransport } from '../../ssh/orcad-host-transport' +import { + createExecProbedStageCommand, + ensureRemoteOrcadNodeRuntime +} from './orcad-remote-node-runtime' +import { + assertRemoteNodeRuntimeStoreExecutable, + RemoteNodeRuntimeNoexecError, + REMOTE_NODE_RUNTIME_MISSING, + REMOTE_NODE_RUNTIME_NOEXEC +} from '../../ssh/orcad-remote-node-runtime-report' +import { classifyOrcadHostUnavailable } from '../../orcad/orcad-host-unavailable' +import { execCommand } from '../../ssh/remote-exec/remote-exec-command' +import { uploadRelayDirectory } from './remote-install-transfers' +import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' + +vi.mock('../../ssh/remote-exec/remote-exec-command', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) +vi.mock('./remote-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 OrcadHostTransport +const host = getRemoteHostPlatform('linux-x64') +// What dash printed for the probe on a Docker host whose /root is a `noexec` tmpfs. +const NOEXEC_PROBE_REPLY = [ + REMOTE_NODE_RUNTIME_NOEXEC, + 'ORCA_RUNTIME_EXIT=126', + '/bin/sh: 1: /root/.orca-remote/runtimes/.stage-node-x-1/.exec-probe: Permission denied' +].join('\n') + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.mocked(execCommand).mockReset() + vi.mocked(uploadRelayDirectory).mockClear() +}) + +describe('pinned-Node runtime store on a host that refuses to run programs', () => { + it.skipIf(process.platform === 'win32')( + 'reports nothing and leaves no probe behind where programs may run', + () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-')) + directories.push(root) + const stage = join(root, 'runtimes', '.stage-node-x-1') + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', createExecProbedStageCommand(host, stage)], + timeoutMs: 30_000 + }) + expect(result.code).toBe(0) + expect(result.stdout.trim()).toBe('') + expect(existsSync(stage)).toBe(true) + expect(readdirSync(stage)).toEqual([]) + } + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'skips the probe when the stage canโ€™t be written, leaving a full disk to the upload', + () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-')) + directories.push(root) + const stage = join(root, '.stage-node-x-1') + mkdirSync(stage, { mode: 0o500 }) + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', createExecProbedStageCommand(host, stage)], + timeoutMs: 30_000 + }) + chmodSync(stage, 0o700) + expect(result).toMatchObject({ code: 0, stdout: '' }) + } + ) + + it('names the noexec store before fetching or uploading the ~40 MiB runtime', async () => { + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) { + return REMOTE_NODE_RUNTIME_MISSING + } + if (command.includes('.exec-probe')) { + return NOEXEC_PROBE_REPLY + } + return '' + }) + const archivePath = vi.fn(async () => '/never/fetched.tar.gz') + + const failure = await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: '/root/.orca-remote/orcad-0.1.0+abc', + target: 'linux-x64-glibc', + archivePath + }).catch((error: unknown) => error) + + expect(classifyOrcadHostUnavailable(failure)).toBe('runtime_noexec') + expect(String(failure)).toContain('/root/.orca-remote/runtimes') + expect(archivePath).not.toHaveBeenCalled() + expect(uploadRelayDirectory).not.toHaveBeenCalled() + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.at(-1)).toMatch(/^rm -rf '\/root\/\.orca-remote\/runtimes\/\.stage-/) + }) + + it('reads exit 126 as noexec in any locale', () => { + const localized = `${REMOTE_NODE_RUNTIME_NOEXEC}\nORCA_RUNTIME_EXIT=126\nsh: 1: .exec-probe: Keine Berechtigung` + expect(() => + assertRemoteNodeRuntimeStoreExecutable(localized, '/root/.orca-remote/runtimes') + ).toThrow(RemoteNodeRuntimeNoexecError) + }) + + it('leaves any other probe failure to the self-test after the upload', async () => { + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('tar -xzf')) { + return 'ORCA_NODE_RUNTIME_READY' + } + if (command.includes(REMOTE_NODE_RUNTIME_MISSING)) { + return REMOTE_NODE_RUNTIME_MISSING + } + if (command.includes('.exec-probe')) { + return `${REMOTE_NODE_RUNTIME_NOEXEC}\nORCA_RUNTIME_EXIT=127\n/bin/sh: bad interpreter` + } + return command.includes('2>/dev/null') ? 'OK' : '' + }) + await ensureRemoteOrcadNodeRuntime({ + conn, + host, + slotDir: '/root/.orca-remote/orcad-0.1.0+abc', + target: 'linux-x64-glibc', + archivePath: async () => archive() + }) + expect(uploadRelayDirectory).toHaveBeenCalledTimes(1) + }) +}) + +function archive(): string { + const dir = mkdtempSync(join(tmpdir(), 'orcad-runtime-noexec-archive-')) + directories.push(dir) + writeFileSync(join(dir, 'node.tar.gz'), 'archive') + return join(dir, 'node.tar.gz') +} diff --git a/src/main/managed-server/install/orcad-remote-node-runtime.ts b/src/main/managed-server/install/orcad-remote-node-runtime.ts index 56ed98b399da..cfaf0c50d180 100644 --- a/src/main/managed-server/install/orcad-remote-node-runtime.ts +++ b/src/main/managed-server/install/orcad-remote-node-runtime.ts @@ -39,7 +39,9 @@ import { assertPosixOrcadHost } from './orcad-remote-host-support' import { withRuntimeStoreLock, type HeldRuntimeStoreLock } from './remote-node-runtime-store-lock' import { assertRemoteNodeRuntimePromoted, + assertRemoteNodeRuntimeStoreExecutable, REMOTE_NODE_RUNTIME_EXIT_PREFIX, + REMOTE_NODE_RUNTIME_NOEXEC, REMOTE_NODE_RUNTIME_MISSING, REMOTE_NODE_RUNTIME_READY, REMOTE_NODE_RUNTIME_SELFTEST_FAILED, @@ -105,6 +107,23 @@ export function nodeRuntimeStageDir( ) } +/** + * Creates the stage and runs a two-line script from it, so a store that can't run programs (a + * `noexec` home) is named before the ~40 MiB upload instead of failing the self-test after it. + */ +export function createExecProbedStageCommand(host: RemoteHostPlatform, stageDir: string): string { + const stage = quotePosixShell(stageDir) + const probe = quotePosixShell(joinRemotePath(host, stageDir, '.exec-probe')) + return ( + // Why the probe write may fail quietly: a full disk is diagnosed by the upload that follows. + `mkdir -p ${stage} && { if printf '#!/bin/sh\\necho ok\\n' > ${probe} 2>/dev/null && chmod 700 ${probe}; then ` + + `orca_x=$(${probe} 2>&1); orca_x_status=$?; rm -f ${probe}; ` + + `[ "$orca_x_status" = 0 ] || { echo ${REMOTE_NODE_RUNTIME_NOEXEC}; ` + + `echo "${REMOTE_NODE_RUNTIME_EXIT_PREFIX}$orca_x_status"; printf '%s\\n' "$orca_x" | head -c 2000; }; ` + + `else rm -f ${probe}; fi; }` + ) +} + // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. export function remoteSha256Command(path: string): string { return `{ sha256sum ${path} 2>/dev/null || shasum -a 256 ${path}; } | cut -d' ' -f1` @@ -290,6 +309,13 @@ export async function ensureRemoteOrcadNodeRuntime(options: { let stageUnconfirmed = false let hostRemovedStage = false try { + // Why before the archive: fetching it may download, and a noexec store would waste both. + if (!windows) { + assertRemoteNodeRuntimeStoreExecutable( + await exec(createExecProbedStageCommand(host, stageDir), { signal }), + remoteDirname(runtimeDir, host) + ) + } const archivePath = await options.archivePath() const uploadDir = await mkdtemp(join(dirname(archivePath), '.runtime-upload-')) localStage = uploadDir @@ -297,9 +323,6 @@ export async function ensureRemoteOrcadNodeRuntime(options: { await link(archivePath, join(uploadDir, archive)).catch(() => copyFile(archivePath, join(uploadDir, archive)) ) - if (!windows) { - await exec(`mkdir -p ${quotePosixShell(stageDir)}`, { signal }) - } await remoteStep(() => uploadRelayDirectory(conn, uploadDir, stageDir, host, { signal })) let promoteRan = false // Why unwrapped on Windows: these are already self-contained powershell.exe command lines. diff --git a/src/main/managed-server/install/orcad-remote-runtime.ts b/src/main/managed-server/install/orcad-remote-runtime.ts index 85fb82db72fb..2f771ecc6019 100644 --- a/src/main/managed-server/install/orcad-remote-runtime.ts +++ b/src/main/managed-server/install/orcad-remote-runtime.ts @@ -10,18 +10,19 @@ import { } from '../../../shared/orcad-artifacts' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { quotePosixShell } from '../../../shared/shell-argument-quoting' -import { - joinRemotePath, - remoteDirname, - type RemoteHostPlatform -} from '../../ssh/ssh-remote-platform' +import { joinRemotePath, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' + +/** `orcad_slot=;`, so the selections below name the long slot path once on the wire. */ +function assignOrcadSlot(directory: string): string { + return `orcad_slot=${quotePosixShell(directory.replace(/\/+$/, ''))}; ` +} /** New slots start the server directly; rollback still accepts older single-entry slots. */ export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: string): string { assertPosixOrcadHost(host) - const server = quotePosixShell(joinRemotePath(host, directory, ORCAD_SERVER_ENTRY_FILENAME)) - const launcher = quotePosixShell(joinRemotePath(host, directory, ORCAD_LAUNCHER_FILENAME)) - return `if [ -f ${server} ]; then orcad_entry=${server}; else orcad_entry=${launcher}; fi` + const server = `"$orcad_slot"/${quotePosixShell(ORCAD_SERVER_ENTRY_FILENAME)}` + const launcher = `"$orcad_slot"/${quotePosixShell(ORCAD_LAUNCHER_FILENAME)}` + return `${assignOrcadSlot(directory)}if [ -f ${server} ]; then orcad_entry=${server}; else orcad_entry=${launcher}; fi` } /** @@ -30,20 +31,13 @@ export function selectOrcadSlotEntryCommand(host: RemoteHostPlatform, directory: */ export function orcadNodeSlotRuntimeCommand(host: RemoteHostPlatform, directory: string): string { assertPosixOrcadHost(host) - const nodeMarker = quotePosixShell( - joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME) - ) - const nodeRuntimePrefix = quotePosixShell( - joinRemotePath( - host, - remoteDirname(directory.replace(/\/+$/, ''), host), - ORCAD_RUNTIMES_DIRNAME, - ORCAD_NODE_RUNTIME_DIR_PREFIX - ) - ) + const nodeMarker = `"$orcad_slot"/${quotePosixShell(ORCAD_NODE_RUNTIME_MARKER_FILENAME)}` + const nodeRuntimePrefix = `"\${orcad_slot%/*}"/${quotePosixShell( + `${ORCAD_RUNTIMES_DIRNAME}/${ORCAD_NODE_RUNTIME_DIR_PREFIX}` + )}` const nodeExecutable = quotePosixShell(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`) return ( - `orcad_node_sha=$(cat ${nodeMarker}) || exit 78; ` + + `${assignOrcadSlot(directory)}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; ' + diff --git a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts index 5c5c098fa14d..665023443b89 100644 --- a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts @@ -45,14 +45,16 @@ import { import { captureOrcadStateSnapshotCommand, compareOrcadStateSnapshotCommand, - newestStateMtimeCommand, orcadSnapshotIsUnchanged, - parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, parseOrcadSnapshotRestore, probeOrcadStateSnapshotCommand, restoreOrcadStateSnapshotCommand } from '../host-state/orcad-state-snapshot' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds +} from '../host-state/orcad-state-newest-mtime' import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' import { ORCAD_STOP_REQUEST_FILENAME } from '../../../shared/orcad-stop-request' diff --git a/src/main/managed-server/install/remote-install-out-of-space.test.ts b/src/main/managed-server/install/remote-install-out-of-space.test.ts new file mode 100644 index 000000000000..d9dbe7e03e90 --- /dev/null +++ b/src/main/managed-server/install/remote-install-out-of-space.test.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process' +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 { RemoteInstallTransport } from './remote-install-transport' +import { uploadRelayDirectory, writeRelayFile } from './remote-install-transfers' +import { + RemoteInstallOutOfSpaceError, + remoteFreeSpaceProbeCommand +} from './remote-install-out-of-space' +import { dfAvailableKb } from '../host-state/remote-filesystem-space' +import { execHostCommand } from '../../ssh/remote-exec/remote-host-exec' +import { getRemoteHostPlatform } from '../../ssh/ssh-remote-platform' + +vi.mock('../../ssh/remote-exec/remote-host-exec', () => ({ + execHostCommand: vi.fn() +})) + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') +// What ssh2 surfaces when OpenSSH's sftp-server hits ENOSPC: SSH_FX_FAILURE, no errno. +const sftpFailure = (): Error => Object.assign(new Error('Failure'), { code: 4 }) +const dfLine = (availableKb: number): string => + `tmpfs 409600 ${409600 - availableKb} ${availableKb} 100% /root\n` + +function connection(failure: () => Error): RemoteInstallTransport { + return { + exec: vi.fn(), + usesSystemSshTransport: () => false, + getConnectGeneration: () => 1, + uploadDirectory: vi.fn(async () => { + throw failure() + }), + writeFile: vi.fn(async () => { + throw failure() + }) + } +} + +let localDir = '' +beforeEach(() => { + localDir = mkdtempSync(join(tmpdir(), 'orca-out-of-space-')) + writeFileSync(join(localDir, 'orcad.js'), Buffer.alloc(2 * 1024 * 1024)) + vi.mocked(execHostCommand).mockReset() +}) +afterEach(() => rmSync(localDir, { recursive: true, force: true })) + +describe('a failed install write on a full host', () => { + it('says the disk is full instead of the bare SFTP "Failure"', async () => { + vi.mocked(execHostCommand).mockResolvedValue(dfLine(12)) + const upload = uploadRelayDirectory( + connection(sftpFailure), + localDir, + '/root/.orca-remote/orcad-0.1.0+abc', + posix + ) + await expect(upload).rejects.toBeInstanceOf(RemoteInstallOutOfSpaceError) + await expect(upload).rejects.toThrow( + /out of disk space: the filesystem holding \/root\/\.orca-remote\/orcad-0\.1\.0\+abc has 12 KB free, and Orca needs about 2\.0 MB/ + ) + expect(vi.mocked(execHostCommand).mock.calls[0]?.[2]).toBe( + remoteFreeSpaceProbeCommand('/root/.orca-remote/orcad-0.1.0+abc') + ) + }) + + it('sizes a single file write by its contents', async () => { + vi.mocked(execHostCommand).mockResolvedValue(dfLine(0)) + await expect( + writeRelayFile(connection(sftpFailure), posix, '/root/.orca-remote/x/.version', 'abc') + ).rejects.toThrow(/has 0 KB free, and Orca needs about 1 KB/) + }) + + it('keeps the original error when the filesystem still has room', async () => { + // Less than the whole bundle, but a partial upload may have spent the rest: not proof. + vi.mocked(execHostCommand).mockResolvedValue(dfLine(1_500)) + const failure = sftpFailure() + await expect( + uploadRelayDirectory( + connection(() => failure), + localDir, + '/root/r', + posix + ) + ).rejects.toBe(failure) + }) + + it('keeps the original error when the space probe itself fails', async () => { + vi.mocked(execHostCommand).mockRejectedValue(new Error('df: not found')) + const failure = sftpFailure() + await expect( + uploadRelayDirectory( + connection(() => failure), + localDir, + '/root/r', + posix + ) + ).rejects.toBe(failure) + }) + + it('keeps explicit refusals such as permission denied without probing', async () => { + const denied = Object.assign(new Error('Permission denied'), { code: 3 }) + await expect( + uploadRelayDirectory( + connection(() => denied), + localDir, + '/root/r', + posix + ) + ).rejects.toBe(denied) + expect(execHostCommand).not.toHaveBeenCalled() + }) + + it('never probes after unverifiable transport loss, an abort, or on Windows', async () => { + const lost = Object.assign(new Error('SSH command timed out'), { + code: 4, + sshChannelCloseConfirmed: false + }) + await expect( + uploadRelayDirectory( + connection(() => lost), + localDir, + '/root/r', + posix + ) + ).rejects.toBe(lost) + const controller = new AbortController() + controller.abort() + await expect( + uploadRelayDirectory(connection(sftpFailure), localDir, '/root/r', posix, { + signal: controller.signal + }) + ).rejects.toThrow() + const failure = sftpFailure() + await expect( + uploadRelayDirectory( + connection(() => failure), + localDir, + 'C:/Users/u/r', + windows + ) + ).rejects.toBe(failure) + expect(execHostCommand).not.toHaveBeenCalled() + }) +}) + +describe('remoteFreeSpaceProbeCommand', () => { + it.skipIf(process.platform === 'win32')( + 'reads free space from the nearest existing directory through a real /bin/sh', + () => { + const missing = join(localDir, "not yet's made", 'stage dir') + const out = execFileSync('/bin/sh', ['-c', remoteFreeSpaceProbeCommand(missing)], { + encoding: 'utf8' + }) + expect(dfAvailableKb(out.trim())).toEqual(expect.any(Number)) + } + ) +}) diff --git a/src/main/managed-server/install/remote-install-out-of-space.ts b/src/main/managed-server/install/remote-install-out-of-space.ts new file mode 100644 index 000000000000..bae699f5cbe7 --- /dev/null +++ b/src/main/managed-server/install/remote-install-out-of-space.ts @@ -0,0 +1,99 @@ +// Names a full disk behind a failed install write. OpenSSH's sftp-server reports ENOSPC and EDQUOT +// as the generic SSH_FX_FAILURE ("Failure"), so the host's free space is the only evidence. + +import { lstat, readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { RemoteInstallTransport } from './remote-install-transport' +import { dfAvailableKb, formatKb } from '../host-state/remote-filesystem-space' +import { execHostCommand } from '../../ssh/remote-exec/remote-host-exec' +import { isUnconfirmedSshCommandTermination } from '../../ssh/remote-exec/remote-exec-command' +import { isWindowsRemoteHost, type RemoteHostPlatform } from '../../ssh/ssh-remote-platform' +import { quotePosixShell } from '../../../shared/shell-argument-quoting' + +const DIAGNOSIS_TIMEOUT_MS = 15_000 +const SSH_FX_FAILURE = 4 +// Why a floor and not the write's size: a partial upload already spent some room, so only a +// filesystem that is effectively empty proves the write ran out of it. +const EXHAUSTED_KB = 1024 + +export class RemoteInstallOutOfSpaceError extends Error { + constructor( + readonly remotePath: string, + readonly availableKb: number, + readonly neededKb: number, + cause: unknown + ) { + super( + `The host is out of disk space: the filesystem holding ${remotePath} has ` + + `${formatKb(availableKb)} free, and Orca needs about ${formatKb(neededKb)} to install ` + + 'its server there. Free up space on the host (or raise your disk quota), then connect again.', + { cause } + ) + this.name = 'RemoteInstallOutOfSpaceError' + } +} + +/** The `df -Pk` line for the deepest directory of `remotePath` that exists. */ +export function remoteFreeSpaceProbeCommand(remotePath: string): string { + return ( + `p=${quotePosixShell(remotePath)}; ` + + `while [ ! -d "$p" ] && [ "$p" != / ] && [ "$p" != . ]; do p=$(dirname "$p"); done; ` + + `df -Pk "$p" 2>/dev/null | tail -n 1` + ) +} + +/** What an upload of `path` writes: uploadDirectory follows the root but skips symlinks inside. */ +export async function localTreeBytes(path: string, root = true): Promise { + const info = await (root ? stat(path) : lstat(path)) + if (info.isSymbolicLink()) { + return 0 + } + if (!info.isDirectory()) { + return info.size + } + let total = 0 + for (const entry of await readdir(path)) { + total += await localTreeBytes(join(path, entry), false) + } + return total +} + +/** + * The out-of-space error when SFTP's generic failure met an exhausted filesystem, else `error` + * itself. A lost or aborted transfer stays as it was: loss of contact says nothing about the disk. + */ +export async function explainRemoteInstallWriteFailure( + conn: RemoteInstallTransport, + host: RemoteHostPlatform, + remotePath: string, + neededBytes: () => Promise, + error: unknown, + signal?: AbortSignal +): Promise { + if ( + isWindowsRemoteHost(host) || + signal?.aborted || + !(error instanceof Error) || + !('code' in error) || + error.code !== SSH_FX_FAILURE || + isUnconfirmedSshCommandTermination(error) + ) { + return error + } + try { + const [line, needed] = await Promise.all([ + execHostCommand(conn, host, remoteFreeSpaceProbeCommand(remotePath), { + signal, + timeoutMs: DIAGNOSIS_TIMEOUT_MS + }), + neededBytes() + ]) + const availableKb = dfAvailableKb(line.trim().split(/\r?\n/u).at(-1)) + const neededKb = Math.ceil(needed / 1024) + return availableKb !== null && availableKb < Math.min(neededKb, EXHAUSTED_KB) + ? new RemoteInstallOutOfSpaceError(remotePath, availableKb, neededKb, error) + : error + } catch { + return error + } +} diff --git a/src/main/managed-server/install/remote-install-transfers.ts b/src/main/managed-server/install/remote-install-transfers.ts index b03a1b691bb1..3b56b3899e77 100644 --- a/src/main/managed-server/install/remote-install-transfers.ts +++ b/src/main/managed-server/install/remote-install-transfers.ts @@ -14,6 +14,7 @@ import { isSandboxedSftpNamespaceError } from '../../ssh/sftp-stream-late-error' import { isSftpHandshakeCorruption } from '../../ssh/sftp-handshake-corruption' +import { explainRemoteInstallWriteFailure, localTreeBytes } from './remote-install-out-of-space' export type RelayTransferOptions = { signal?: AbortSignal @@ -32,6 +33,7 @@ export async function uploadRelayDirectory( hostPlatform, shellRemoteDir, options?.signal, + () => localTreeBytes(localRelayDir), () => conn.uploadDirectory(localRelayDir, shellRemoteDir, { hostPlatform, @@ -57,6 +59,7 @@ export async function writeRelayFile( hostPlatform, shellRemotePath, options?.signal, + async () => Buffer.byteLength(contents), () => conn.writeFile(shellRemotePath, contents, { hostPlatform, @@ -113,6 +116,29 @@ function connectGeneration(conn: RemoteInstallTransport): number { } async function transferWithExecStdinFallback( + conn: RemoteInstallTransport, + hostPlatform: RemoteHostPlatform, + remotePath: string, + signal: AbortSignal | undefined, + neededBytes: () => Promise, + sftpTransfer: () => Promise, + execStdinTransfer: () => Promise +): Promise { + try { + await transferOnce(conn, hostPlatform, remotePath, signal, sftpTransfer, execStdinTransfer) + } catch (error) { + throw await explainRemoteInstallWriteFailure( + conn, + hostPlatform, + remotePath, + neededBytes, + error, + signal + ) + } +} + +async function transferOnce( conn: RemoteInstallTransport, hostPlatform: RemoteHostPlatform, remotePath: string, diff --git a/src/main/managed-server/install/remote-node-runtime-store-lock.ts b/src/main/managed-server/install/remote-node-runtime-store-lock.ts index a7eb74d07632..e947712f7250 100644 --- a/src/main/managed-server/install/remote-node-runtime-store-lock.ts +++ b/src/main/managed-server/install/remote-node-runtime-store-lock.ts @@ -45,7 +45,7 @@ function lockDir(host: RemoteHostPlatform, storeDir: string): string { return joinRemotePath(host, storeDir, RUNTIME_STORE_LOCK_NAME) } -function posixReleaseOwnedLockCommand( +export function posixReleaseOwnedLockCommand( host: RemoteHostPlatform, lock: string, owner: InstallLockOwnerFile diff --git a/src/main/managed-server/lifecycle/orcad-rollback-transition.ts b/src/main/managed-server/lifecycle/orcad-rollback-transition.ts index 0a33e31152b3..658276d67bab 100644 --- a/src/main/managed-server/lifecycle/orcad-rollback-transition.ts +++ b/src/main/managed-server/lifecycle/orcad-rollback-transition.ts @@ -10,15 +10,17 @@ import { assessOrcadRollback } from '../../ssh/orcad-update-plan' import { ORCAD_LOG_FILENAME } from '../install/orcad-remote-host-support' import { captureOrcadStateSnapshotCommand, - newestStateMtimeCommand, orcadRollbackRescueDirName, - parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, parseOrcadSnapshotPresence, parseOrcadSnapshotRestore, probeOrcadStateSnapshotCommand, restoreOrcadStateSnapshotCommand } from '../host-state/orcad-state-snapshot' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds +} from '../host-state/orcad-state-newest-mtime' import { joinRemotePath } from '../../ssh/ssh-remote-platform' import type { OrcadActivationLockControl } from '../activation/orcad-activation-lock' import { diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts index cc8ea55398ee..d280bc0cfaa9 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts @@ -245,7 +245,7 @@ describe('the source frozen during a conversion', () => { it('still refuses a catalog change mid-conversion', async () => { const stage = destination.stage.getMockImplementation()! destination.stage.mockImplementationOnce(async (manifest) => { - store.updateRepo('repo-1', { displayName: 'Renamed' }) + store.updateRepo(store.getRepo('repo-1')!, { displayName: 'Renamed' }) return stage(manifest) }) await expect(convert()).rejects.toThrow('orcad_migration_source_changed') diff --git a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts index 47256c88e82f..a8ed8ff27b08 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-deployment.ts @@ -41,6 +41,7 @@ import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { tunneledOrcadPairingCode } from '../../ssh/orcad-tunneled-pairing' import { resolveOrcadMigrationFence } from '../../ssh/orcad-migration-source-fence' import type { SshTarget } from '../../../shared/ssh-types' +import { findCollidingEnvironmentName } from '../../../shared/runtime-environment-name-collision' import { deployedOrcadTunnelChecks } from './orcad-managed-tunnel-identity' import { isForceableOrcadDeferral, @@ -73,8 +74,12 @@ export async function createManagedOrcadEnvironment( throw new Error('The SSH target is owned by a server that is not deployed on it.') } } - if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { - throw new Error(`A server named "${args.name}" already exists.`) + const namesake = findCollidingEnvironmentName( + environments.filter((entry) => entry.id !== environmentId), + args.name + ) + if (namesake) { + throw new Error(`A server named "${namesake.name}" already exists.`) } const current = targetStore.getTarget(args.sshTargetId) if (args.migration) { diff --git a/src/main/managed-server/posix-host-op-byte-budget.test.ts b/src/main/managed-server/posix-host-op-byte-budget.test.ts new file mode 100644 index 000000000000..efd3950dc38a --- /dev/null +++ b/src/main/managed-server/posix-host-op-byte-budget.test.ts @@ -0,0 +1,354 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildPosixStdoutFence } from '../../shared/posix-stdout-fence' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import { initialOrcadActivationAdmissionCommand } from '../ssh/orcad-initial-activation-admission' +import { + captureOrcadLaunchSshSessionCommand, + orcadLaunchSshSessionProbeCommand, + writeOrcadLaunchSshSessionCommand +} from '../ssh/orcad-launch-ssh-session' +import { makeExecStdinWriteFileCommand } from '../ssh/ssh-exec-stdin-file-transfer' +import { wrapRemoteCommandForPosixShell } from '../ssh/ssh-posix-command-wrapper' +import { + listRemoteInstallBaseDirsCommand, + moveRemoteTreeCommand, + probeFileExistsCommand, + probeRemoteInstallCompleteCommand, + readRemoteHomeCommand, + removeRemoteFileCommand, + removeRemoteTreeCommand, + restoreRemoteTreeCommand, + writeRemoteEmptyFileCommand +} from '../ssh/ssh-remote-commands' +import { getRemoteHostPlatform, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { makePosixWriteFileCommand } from '../ssh/system-transport/system-ssh-file-binary-transfer' +import { + posixOrcadFenceGuard, + runWithOrcadFence, + type OrcadFence +} from './activation/orcad-activation-fence-scope' +import { posixReleaseFenceCommand } from './activation/orcad-activation-lock' +import { + captureOrcadStateSnapshotCommand, + clearOrcadStateSnapshotMembersCommand, + compareOrcadStateSnapshotCommand, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand, + serializedStateMutationCommand +} from './host-state/orcad-state-snapshot' +import { newestStateMtimeCommand } from './host-state/orcad-state-newest-mtime' +import { orcadSnapshotSpaceCommand } from './host-state/orcad-state-snapshot-space' +import { posixProcessGroupCommand } from './host-state/orcad-state-mutation-owner-record' +import { orcadMachineClaimCommand } from './host-state/orcad-machine-claim-command' +import { orcadMachineHomeProbeCommand } from './host-state/orcad-machine-home' +import { executablePermissionsCommand } from './install/orcad-remote-install' +import { + orcadNodeSlotRuntimeCommand, + selectOrcadSlotEntryCommand, + selectOrcadSlotRuntimeCommand +} from './install/orcad-remote-runtime' +import { + orcadLaunchCommand, + orcadLivenessProbeCommand, + readOrcadReadinessCommand +} from './install/orcad-remote-launch' +import { + createExecProbedStageCommand, + installNodeRuntimeFromHostArchiveCommand, + probeRemoteNodeRuntimeCommand, + promoteRemoteNodeRuntimeCommand, + recheckAndPromoteRemoteNodeRuntimeCommand, + remoteNodeRuntimePresentCommand, + remoteSha256Command +} from './install/orcad-remote-node-runtime' +import { remoteOrcadBuildHashCommand } from './install/orcad-remote-build-hash' +import { remoteFreeSpaceProbeCommand } from './install/remote-install-out-of-space' +import { sweepStaleRuntimeStagesCommand } from './install/remote-node-runtime-store-gc' +import { orcadProfilePreflightCommand } from './install/orcad-remote-preflight' +import { + recordedNodeRuntimeProbeCommand, + recordedRemoteNodeRuntimeProbeCommand +} from './install/orcad-remote-node-runtime-recorded-probe' +import { runtimeStoreInventoryCommand } from './install/remote-node-runtime-store-inventory' +import { posixReleaseOwnedLockCommand } from './install/remote-node-runtime-store-lock' +import { ORCAD_INSTALL_MODEL } from './install/remote-install-model' +import { stopOrcadCommand } from './install/orcad-remote-process-control' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + orphanInstallLockCommand, + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './install/remote-install-lock-commands' +import { orcadLogTailCommand } from './install/orcad-remote-log-tail' +import { + orcadReadinessWaitCommand, + readOrcadReadinessNowCommand +} from './install/orcad-remote-readiness-wait' +import { orcadPosixStdioBridgeCommand } from './lifecycle/orcad-stdio-bridge-script' + +// Why 6 KB: gateways cut exec requests near 10 KB (#30491). This bounds the wire string: the +// fence-guarded command, wrapped uncompressed as a host without gzip/base64 receives it. +const POSIX_HOST_OP_BYTE_BUDGET = 6 * 1024 + +// Ops already past the budget, at their measured size. Ceilings may only shrink. +const OVER_BUDGET_CEILINGS: Readonly> = { + tryStealInstallLockCommand: 9_022, + orcadLaunchCommand: 8_122, + 'orcadLaunchCommand (WSL setsid)': 6_439, + probeRemoteInstallCompleteCommand: 12_189 +} + +type HostCase = { host: RemoteHostPlatform; target: NodeRuntimeTarget; nodePath: string } + +// Realistic worst case: a long NFS home, a long account name and a held activation fence. +const home = `/net/nfs-home-cluster-03/users/engineering/${'a'.repeat(48)}.longname` +const baseDir = `${home}/.orca-remote` +const slotDir = `${baseDir}/orcad-1.4.299-rc.12+${'f'.repeat(40)}` +const userDataDir = `${baseDir}/orcad-data` +const snapshotDir = `${baseDir}/orcad-snapshots/pre-1.4.299-rc.12-1760000000000` +const installLock = `${slotDir}.install-lock` +const token = 'f'.repeat(64) +const fence: OrcadFence = { lockDir: `${baseDir}/orcad-activation.lock`, token } +const owner = { fileName: 'owner', token } + +function hostCase(platform: 'linux-x64' | 'darwin-arm64', target: NodeRuntimeTarget): HostCase { + const nodePath = `${baseDir}/node-runtimes/v22.20.0-${target}/bin/node` + return { host: getRemoteHostPlatform(platform), target, nodePath } +} + +const HOST_CASES: readonly HostCase[] = [ + hostCase('linux-x64', 'linux-x64-glibc'), + hostCase('darwin-arm64', 'darwin-arm64') +] + +function launchSpec(nodePath: string): Parameters[1] { + return { + remoteInstallDir: slotDir, + nodePath, + fullVersion: '1.4.299-rc.12', + userDataDir, + bindHost: '127.0.0.1', + port: 65535, + activationRoot: `${baseDir}/orcad-activation` + } +} + +function promoteArgs({ + nodePath, + target +}: HostCase): Parameters[1] { + return { + stageDir: `${nodePath}.stage-${token}`, + archive: `${baseDir}/node-runtimes/archive.tar.xz`, + runtimeDir: nodePath, + target, + token + } +} + +const BUILDERS: Readonly string>> = { + serializedStateMutationCommand: () => serializedStateMutationCommand(baseDir, ':', 20, fence), + // As activation runs it: pruning every other capture but the recorded one. + captureOrcadStateSnapshotCommand: ({ host }) => + captureOrcadStateSnapshotCommand(host, userDataDir, snapshotDir, baseDir, { + snapshotsRoot: `${baseDir}/orcad-snapshots`, + keep: [snapshotDir.slice(snapshotDir.lastIndexOf('/') + 1), 'pre-1.4.298-rc.3-1750000000000'] + }), + probeOrcadStateSnapshotCommand: ({ host }) => + probeOrcadStateSnapshotCommand(host, snapshotDir, baseDir), + restoreOrcadStateSnapshotCommand: ({ host }) => + restoreOrcadStateSnapshotCommand(host, userDataDir, snapshotDir, baseDir), + clearOrcadStateSnapshotMembersCommand: ({ host }) => + clearOrcadStateSnapshotMembersCommand(host, userDataDir, baseDir), + compareOrcadStateSnapshotCommand: ({ host }) => + compareOrcadStateSnapshotCommand(host, userDataDir, snapshotDir, baseDir), + newestStateMtimeCommand: ({ host }) => newestStateMtimeCommand(host, userDataDir, baseDir), + orcadSnapshotSpaceCommand: ({ host }) => + orcadSnapshotSpaceCommand(host, userDataDir, snapshotDir, baseDir, ['keep-a', 'keep-b']) ?? '', + posixProcessGroupCommand: () => posixProcessGroupCommand('"$pid"'), + orcadMachineClaimCommand: ({ host }) => orcadMachineClaimCommand(host, home, 'k'.repeat(64)), + orcadMachineHomeProbeCommand: ({ host }) => orcadMachineHomeProbeCommand(host), + selectOrcadSlotEntryCommand: ({ host }) => selectOrcadSlotEntryCommand(host, slotDir), + orcadNodeSlotRuntimeCommand: ({ host }) => orcadNodeSlotRuntimeCommand(host, slotDir), + selectOrcadSlotRuntimeCommand: ({ host, nodePath }) => + selectOrcadSlotRuntimeCommand(host, slotDir, nodePath), + // As SSH launches it; on macOS it embeds the launch-session capture and record. + orcadLaunchCommand: ({ host, nodePath }) => + orcadLaunchCommand(host, launchSpec(nodePath), 'nohup', []), + 'orcadLaunchCommand (WSL setsid)': ({ host, nodePath }) => + orcadLaunchCommand(host, launchSpec(nodePath), 'setsid', [ + ['ORCA_EXAMPLE_HOST_ENV', 'x'.repeat(200)] + ]), + readOrcadReadinessCommand: ({ host }) => readOrcadReadinessCommand(host, slotDir), + orcadLivenessProbeCommand: ({ host }) => orcadLivenessProbeCommand(host, slotDir), + remoteSha256Command: ({ nodePath }) => remoteSha256Command(`'${nodePath}'`), + remoteFreeSpaceProbeCommand: ({ nodePath }) => remoteFreeSpaceProbeCommand(nodePath), + createExecProbedStageCommand: ({ host, nodePath }) => + createExecProbedStageCommand(host, `${nodePath}.stage-${token}`), + probeRemoteNodeRuntimeCommand: ({ host, nodePath, target }) => + probeRemoteNodeRuntimeCommand(host, nodePath, target), + remoteNodeRuntimePresentCommand: ({ host, nodePath }) => + remoteNodeRuntimePresentCommand(host, nodePath, true), + promoteRemoteNodeRuntimeCommand: (c) => promoteRemoteNodeRuntimeCommand(c.host, promoteArgs(c)), + recheckAndPromoteRemoteNodeRuntimeCommand: (c) => + recheckAndPromoteRemoteNodeRuntimeCommand(c.host, promoteArgs(c)), + installNodeRuntimeFromHostArchiveCommand: ({ host, nodePath, target }) => + installNodeRuntimeFromHostArchiveCommand(host, { + runtimeDir: nodePath, + archive: `${baseDir}/node-runtimes/archive.tar.xz`, + target, + token + }), + remoteOrcadBuildHashCommand: ({ host }) => remoteOrcadBuildHashCommand(host, slotDir), + sweepStaleRuntimeStagesCommand: ({ host }) => + sweepStaleRuntimeStagesCommand(`${baseDir}/node-runtimes`, host), + orcadProfilePreflightCommand: ({ host }) => orcadProfilePreflightCommand(host, slotDir, token), + recordedNodeRuntimeProbeCommand: ({ host, nodePath }) => + recordedNodeRuntimeProbeCommand(host, nodePath, token), + recordedRemoteNodeRuntimeProbeCommand: ({ host, nodePath, target }) => + recordedRemoteNodeRuntimeProbeCommand(host, nodePath, target), + runtimeStoreInventoryCommand: ({ host }) => runtimeStoreInventoryCommand(host, home), + stopOrcadCommand: ({ host, nodePath }) => + stopOrcadCommand(host, slotDir, { waitSeconds: 30, nodePath }), + acquireInstallLockParentCommand: ({ host }) => acquireInstallLockParentCommand(host, baseDir), + tryCreateInstallLockCommand: ({ host }) => tryCreateInstallLockCommand(host, installLock, owner), + probeInstallLockExistsCommand: ({ host }) => probeInstallLockExistsCommand(host, installLock), + lockAgeSecondsCommand: ({ host }) => lockAgeSecondsCommand(host, installLock), + orphanInstallLockCommand: ({ host }) => orphanInstallLockCommand(host, installLock), + tryStealInstallLockCommand: ({ host }) => + tryStealInstallLockCommand(host, installLock, 120, owner, { + ...owner, + quietSeconds: 60, + mutationLock: `${baseDir}/orcad-state-mutation.lock` + }), + orcadLogTailCommand: ({ host }) => orcadLogTailCommand(host, slotDir), + orcadReadinessWaitCommand: ({ host }) => orcadReadinessWaitCommand(host, slotDir, 120), + readOrcadReadinessNowCommand: ({ host }) => readOrcadReadinessNowCommand(host, slotDir), + orcadPosixStdioBridgeCommand: () => orcadPosixStdioBridgeCommand(65535), + posixReleaseFenceCommand: () => + posixReleaseFenceCommand( + fence, + `${baseDir}/orcad-activation/journal.json`, + `${baseDir}/orcad-activation` + ), + posixReleaseOwnedLockCommand: ({ host }) => + `${posixReleaseOwnedLockCommand(host, `${baseDir}/node-runtimes/.store-lock`, owner)} || true`, + executablePermissionsCommand: ({ host, target }) => + executablePermissionsCommand(host, slotDir, target), + readRemoteHomeCommand: ({ host }) => readRemoteHomeCommand(host), + removeRemoteFileCommand: ({ host }) => removeRemoteFileCommand(host, `${slotDir}/.orcad-pid`), + removeRemoteTreeCommand: ({ host }) => removeRemoteTreeCommand(host, slotDir), + moveRemoteTreeCommand: ({ host }) => + moveRemoteTreeCommand(host, slotDir, `${slotDir}.trash-${token}`), + restoreRemoteTreeCommand: ({ host }) => + restoreRemoteTreeCommand(host, `${slotDir}.trash-${token}`, slotDir), + writeRemoteEmptyFileCommand: ({ host }) => + writeRemoteEmptyFileCommand(host, `${slotDir}/.complete`), + probeRemoteInstallCompleteCommand: ({ host }) => + probeRemoteInstallCompleteCommand(host, slotDir, [ + ...ORCAD_INSTALL_MODEL.requiredArtifacts(host), + ORCAD_INSTALL_MODEL.installCompleteFilename + ]), + listRemoteInstallBaseDirsCommand: ({ host }) => + listRemoteInstallBaseDirsCommand(host, baseDir, ORCAD_INSTALL_MODEL), + probeFileExistsCommand: ({ host }) => probeFileExistsCommand(host, `${slotDir}/.complete`), + captureOrcadLaunchSshSessionCommand: () => captureOrcadLaunchSshSessionCommand(), + writeOrcadLaunchSshSessionCommand: ({ host }) => + writeOrcadLaunchSshSessionCommand(host, userDataDir), + orcadLaunchSshSessionProbeCommand: ({ host }) => + orcadLaunchSshSessionProbeCommand(host, slotDir, userDataDir), + initialOrcadActivationAdmissionCommand: ({ host, nodePath }) => + initialOrcadActivationAdmissionCommand(host, userDataDir, slotDir, nodePath), + makeExecStdinWriteFileCommand: () => + makeExecStdinWriteFileCommand(`${slotDir}/orcad.js`, Number.MAX_SAFE_INTEGER, { + token, + executable: true + }), + makePosixWriteFileCommand: () => + makePosixWriteFileCommand(`${slotDir}/orcad.js`, { exclusive: true }) +} + +// Exported builders that never produce a POSIX host op, and why. +const NOT_POSIX_HOST_OPS: Readonly> = { + powerShellCommand: 'builds the Windows PowerShell exec line', + spawnProxyCommand: 'spawns a local ProxyCommand process', + buildSshLoginShellCommand: 'wraps a caller-built op in the login shell', + isSnapshotCaptureCommand: 'a predicate over a command, not a builder', + execCommand: 'the exec entry point every measured op goes through', + execHostCommand: 'the exec entry point every measured op goes through', + spawnSystemSshCommand: 'spawns the local ssh client' +} + +// Not ssh/legacy-relay: the legacy relay retires 2027-01-15. +const SCANNED_DIRS = [ + ...['host-state', 'install', 'lifecycle', 'activation'].map((dir) => join(__dirname, dir)), + join(__dirname, '..', 'ssh'), + ...['system-transport', 'connection', 'remote-exec'].map((dir) => + join(__dirname, '..', 'ssh', dir) + ) +] + +function exportedCommandBuilders(): string[] { + const names: string[] = [] + for (const root of SCANNED_DIRS) { + for (const file of readdirSync(root)) { + if ( + !file.endsWith('.ts') || + /\.test(-fixture)?\.ts$/.test(file) || + file.endsWith('.spec.ts') + ) { + continue + } + if (/windows/i.test(file)) { + continue + } + const source = readFileSync(join(root, file), 'utf8') + for (const match of source.matchAll(/^export (?:async )?function (\w+Command)\(/gm)) { + names.push(match[1]) + } + } + } + return names.sort() +} + +// The largest wire string across POSIX host OSes; guarded even where the op can run unfenced. +async function sentBytes(build: (c: HostCase) => string): Promise { + let max = 0 + for (const c of HOST_CASES) { + const command = await runWithOrcadFence(fence, async () => build(c)) + // execCommand frames stdout before the transport wraps; 16 chars is the longest real nonce. + const framed = buildPosixStdoutFence( + `${posixOrcadFenceGuard(fence)} ${command}`, + 'SSH', + 'z'.repeat(16) + ).command + const sent = wrapRemoteCommandForPosixShell(framed, { compress: false }) + max = Math.max(max, Buffer.byteLength(sent, 'utf8')) + } + return max +} + +describe('POSIX host-op byte budget', () => { + it('measures every exported POSIX host-op builder', () => { + const unmeasured = exportedCommandBuilders().filter( + (name) => !(name in BUILDERS) && !(name in NOT_POSIX_HOST_OPS) + ) + expect(unmeasured).toEqual([]) + }) + + it.each(Object.keys(BUILDERS))('%s stays under the sent byte budget', async (name) => { + const bytes = await sentBytes(BUILDERS[name]) + const ceiling = OVER_BUDGET_CEILINGS[name] + if (ceiling === undefined) { + expect(bytes).toBeLessThanOrEqual(POSIX_HOST_OP_BYTE_BUDGET) + return + } + // A listed op must stay listed only while it is actually over budget. + expect(bytes).toBeGreaterThan(POSIX_HOST_OP_BYTE_BUDGET) + expect(bytes).toBeLessThanOrEqual(ceiling) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts index 7cdedc6c12e1..af66dcce2df9 100644 --- a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -36,7 +36,9 @@ export function adoptedTranscriptSourceObserver(input: { maxSourceBytes: number }) { let sessionId: string | null = - input.agent === 'claude' || input.agent === 'codex' ? sessionIdFromFileName(input.filePath) : null + input.agent === 'claude' || input.agent === 'codex' + ? sessionIdFromFileName(input.filePath) + : null let cwd: string | null = null let sawCodexMetadata = false return { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 80ad7ab6bfd1..232db1502832 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -10,7 +10,11 @@ // never be spliced into a sequence space that a structured session is also // writing; a later structured resume rolls the epoch again and rebuilds. -import { adoptedTranscriptSourceObserver, validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' +import { + adoptedTranscriptSourceObserver, + validateAdoptedOpenCodeSource, + type AdoptedTranscriptSource +} from './adopted-transcript-source' import { createReadStream } from 'node:fs' import type { AgentType } from '../../../shared/agent-status-types' import type { @@ -215,10 +219,14 @@ async function decodeReplacementItems(input: { source?: AdoptedTranscriptSource limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { - const source = input.source ? adoptedTranscriptSourceObserver({ - agent: input.agent, filePath: input.filePath, source: input.source, - maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES - }) : null + const source = input.source + ? adoptedTranscriptSourceObserver({ + agent: input.agent, + filePath: input.filePath, + source: input.source, + maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES + }) + : null const tracker = createLegacyIdentityTracker({ transcriptAgent: input.transcriptAgent, agent: input.agent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts new file mode 100644 index 000000000000..8099ad8093a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts @@ -0,0 +1,114 @@ +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' +import type { StructuredAgentRegistration } from './structured-agent-registry' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export type SessionRoute = { + registration: StructuredAgentRegistration + state: 'acquiring' | 'live' | 'stopped' + acquisition?: StructuredAgentSessionAcquisitionReceipt +} + +/** The route retains the actual startup identity until its exit is durably acknowledged. */ +export async function acquireSessionRoute( + input: StructuredAgentSessionAcquireInput, + registration: StructuredAgentRegistration, + routes: Map, + isClosed: () => boolean +) { + const route: SessionRoute = { + registration, + state: 'acquiring', + ...(input.attemptId && input.launch + ? { + acquisition: { + attemptId: input.attemptId, + fence: input.fence, + spawnToken: input.spawnToken, + location: input.launch.location + } + } + : {}) + } + routes.set(input.identity.sessionId, route) + try { + const acquired = await registration.adapter.acquire({ + ...input, + onSpawned: async (process) => { + if (route.acquisition) { + route.acquisition.process = process + } + await input.onSpawned?.(process) + } + }) + if (route.acquisition) { + route.acquisition.process = acquired.process + } + if (isClosed()) { + throw new Error('structured session adapter router is closed') + } + route.state = 'live' + return acquired + } catch (error) { + if ( + routes.get(input.identity.sessionId) === route && + !registration.adapter.hasOwnedAcquisition?.(input.identity.sessionId) + ) { + routes.delete(input.identity.sessionId) + } + throw error + } +} + +/** Releases only the actual routed acquisition, retaining its observed exit until acknowledged. */ +export async function releaseSessionRoute( + input: { sessionId: string }, + routes: Map, + adapters: () => StructuredAgentSessionAdapter[] +): Promise { + const route = routes.get(input.sessionId) + if (route) { + if (route.state === 'stopped') { + return true + } + if (!route.registration.adapter.hasOwnedAcquisition?.(input.sessionId)) { + if (routes.get(input.sessionId) === route) { + routes.delete(input.sessionId) + } + return false + } + try { + let exitReport: Error | undefined + const stopped = await stopAgentSessionProviderRoot( + () => route.registration.adapter.releaseAcquisition?.(input) ?? Promise.resolve(false), + (error) => { + exitReport = error + } + ) + if (stopped && routes.get(input.sessionId) === route) { + route.state = 'stopped' + } + if (exitReport) { + throw exitReport + } + return stopped + } catch (error) { + if ( + routes.get(input.sessionId) === route && + route.state !== 'stopped' && + !route.registration.adapter.hasOwnedAcquisition?.(input.sessionId) + ) { + routes.delete(input.sessionId) + } + throw error + } + } + let released = false + for (const candidate of adapters()) { + released = (await candidate.releaseAcquisition?.(input)) === true || released + } + return released +} 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 27c4a323506d..8545e6009a9c 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 @@ -39,6 +39,7 @@ function adapterOf( return { acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), releaseAcquisition, + hasOwnedAcquisition: () => true, dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -47,7 +48,7 @@ function adapterOf( } describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { - it('drops the owner even when its release reports a typed failure', async () => { + it('retains the actual owner after an unproved release for a scoped retry', async () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) @@ -56,10 +57,9 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) - // With no owner left, a later release asks every adapter instead of the stale one. await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) - expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1) + expect(codex.releaseAcquisition).not.toHaveBeenCalled() }) }) @@ -359,10 +359,9 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { await router.closeAll() resolveAcquire(acquisition(2, 'spawn-2')) - // The route is NOT published behind a closed adapter, so nothing routes back out to it โ€” and - // with no route the router has nothing to stop and no stop to report. + // Shutdown preserves the stop receipt for an acquisition that resolves behind it. await expect(acquiring).rejects.toThrow('router is closed') - await expect(router.closeSession('session-1')).resolves.toBe(false) + await expect(router.closeSession('session-1')).resolves.toBe(true) expect(closeSession).not.toHaveBeenCalled() }) }) 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 f0c22c24f18b..46c447319456 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 @@ -5,16 +5,18 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry } from './structured-agent-registry' -type SessionRoute = { - registration: StructuredAgentRegistration - state: 'live' | 'stopped' -} +import { + acquireSessionRoute, + releaseSessionRoute, + type SessionRoute +} from './structured-agent-session-acquisition-route' /** Routes each session to the adapter its agent is registered with. Adding an agent is one more * registration; nothing here names one. */ @@ -44,28 +46,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi throw new Error('structured session adapter router is closed') } const registration = this.requireAgent(input.identity) - const acquired = await registration.adapter.acquire(input) - if (this.allAdaptersClosed) { - throw new Error('structured session adapter router is closed') - } - this.routes.set(input.identity.sessionId, { registration, state: 'live' }) - return acquired + return acquireSessionRoute(input, registration, this.routes, () => this.allAdaptersClosed) } async releaseAcquisition(input: { sessionId: string }): Promise { - const route = this.routes.get(input.sessionId) - if (route) { - try { - return (await route.registration.adapter.releaseAcquisition?.(input)) === true - } finally { - this.routes.delete(input.sessionId) - } - } - let released = false - for (const candidate of this.adapters()) { - released = (await candidate.releaseAcquisition?.(input)) === true || released - } - return released + return releaseSessionRoute(input, this.routes, () => this.adapters()) } open: NonNullable = (input) => { @@ -242,6 +227,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } + if (route.state === 'acquiring') { + return this.releaseAcquisition({ sessionId }) + } const { adapter } = route.registration const stop = selectStop(adapter) const stopped = await stop?.call(adapter, sessionId) @@ -252,6 +240,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return false } + hasOwnedAcquisition = (sessionId: string): boolean => { + const route = this.routes.get(sessionId) + return ( + (route?.state === 'stopped' && !!route.acquisition) || + route?.registration.adapter.hasOwnedAcquisition?.(sessionId) === true + ) + } + + stoppedAcquisition = ( + sessionId: string + ): StructuredAgentSessionAcquisitionReceipt | undefined => { + const route = this.routes.get(sessionId) + return route?.state === 'stopped' ? route.acquisition : undefined + } + async closeAll(): Promise { if (this.allAdaptersClosed) { return @@ -278,8 +281,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } /** Drops a per-session stop receipt after the host releases its durable owner. */ - acknowledgeSessionRelease = (sessionId: string): void => { - this.routes.delete(sessionId) + acknowledgeSessionRelease = ( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void => { + if (!acquisition || this.stoppedAcquisition(sessionId) === acquisition) { + this.routes.delete(sessionId) + } } private owner(sessionId: string): StructuredAgentSessionAdapter { 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 d19106525ed5..3eaf8c236d6c 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 @@ -53,7 +53,10 @@ export type { 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' -import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import type { + StructuredAgentSessionStartupAttempt, + StructuredAgentSessionAcquisitionReceipt +} from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' @@ -284,6 +287,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise + /** Whether this adapter owns a child, failed acquisition, or unacknowledged acquisition receipt. */ + hasOwnedAcquisition?(sessionId: string): boolean + stoppedAcquisition?(sessionId: string): StructuredAgentSessionAcquisitionReceipt | undefined dispatch(input: { sessionId: string clientMessageId: string @@ -439,5 +445,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Stops a provider child for teardown without requiring a future-resume cursor. */ disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ - acknowledgeSessionRelease?(sessionId: string): void + acknowledgeSessionRelease?( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index a641a5b285f1..193c764dcd05 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -69,7 +69,10 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) + return context.tasks.trackAttach(runAttach(context, callerKey, params, options), { + sessionId: params.envelope.sessionId, + location: params.location + }) } export function attachStructuredAgentSession( @@ -85,7 +88,8 @@ export function attachStructuredAgentSession( context.tasks.trackAttach( context.serialize(sessionId, () => runAttach(context, callerKey, params, { ...options, recordPhase }) - ) + ), + { sessionId, location: params.location } ) if (params.envelope.expectedRuntimeFence !== null) { return run() 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 index 67bea7e79710..33443a00d8ac 100644 --- 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 @@ -74,7 +74,7 @@ describe('the background-task channel', () => { // A task that finished while no pane listened, then an idle sweep that stopped the provider: // a pane resuming must read "none", not silence it would take as "unchanged" (#24227). - // An idle parent whose live background child the store had no room for: no row lists it, so + // An idle parent whose live background child the store refused: no row lists it, so // the strip must stay and say so rather than read as nothing running. it('keeps the strip, with its Stop, for work the host could not record', () => { const { sessions, channel, session } = channelOver( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 0fa4b7c77099..60dda17a1573 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -8,6 +8,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { @@ -185,11 +186,19 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } context.publishStatus?.(sessionId) } + const wakeQueuedDelivery = (): void => { + if ( + !session.journal.submissions || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + context.wakeDelivery?.(sessionId) + } + } const record = context.store.getRecord(sessionId) if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. endChild() - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() return } let settlement: StructuredAgentSessionExitSettlement | null = null @@ -293,7 +302,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Stop's own) issued at the fence before. Either way every reader learns the generation ended // and the queued-card drain runs, whether or not the release or the settlement was written. context.generationEnded(sessionId, { restate: released && !expected }) - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts index 5a3f70c2d1fe..5f608639aa59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts @@ -1,7 +1,7 @@ // A Claude background helper through the shipping adapter, host and child records: its saved row // stays working past its spawn result and its turn's end until its own end frame; and a helper the -// store had no room for keeps the strip incomplete, and the agent past idle cleanup, until the -// strip's Stop ends the agent. +// store refused keeps the strip incomplete, and the agent past idle cleanup, until the strip's Stop +// ends the agent. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -9,8 +9,6 @@ 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 { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' -import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { isSubagentBlock } from '../../../shared/native-chat-types' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' @@ -184,38 +182,9 @@ it("keeps a background helper's row working past its spawn result and its turn's ) }) -it("keeps an agent whose helper the store had no room for past idle cleanup, until the strip's Stop", async () => { - // Another session holds every child record the store has room for. - const busy = makeStructuredAgentStatusSubject( - { executionHostId: 'local', wslDistro: null, workspaceId: 'busy', workspaceKind: 'folder' }, - 'busy-session' - ) - records.sink.publish( - { - sessionId: 'busy-session', - workspaceId: 'busy', - agent: 'claude', - status: 'working', - latestPrompt: 'Busy', - updatedAt: 1 - }, - busy - ) - records.sink.publishChildWork?.( - busy, - Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => ({ - type: 'live' as const, - observedAt: 1, - child: { - handle: { idKind: 'task_id' as const, id: `busy-${index}` }, - kind: 'agent' as const, - residency: 'background' as const, - state: 'working' as const, - stoppable: true - } - })), - 'claude' - ) +it("keeps an agent whose helper the store refused past idle cleanup, until the strip's Stop", async () => { + // The store refuses every child record this session reports. + records.sink.publishChildWork = () => undefined const connection = claude.connections[0]! await sendAndEcho(connection, 'Run it in the background.') for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 3_349).entries()) { 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 8c066130d213..da63a25dee2c 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 @@ -28,6 +28,8 @@ import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { stopWorkspaceSleepChat } from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { providerStartupFailureFact, @@ -89,6 +91,81 @@ export function createStructuredAgentSessionConversationLifetime(host: { const providerHoldsDispatch = (sessionId: string): boolean => deps().adapter.holdsDispatch?.(sessionId) === true + const holdStopClose = async ( + sessionId: string, + stop: () => Promise, + onCloseError?: (error: unknown) => void + ): Promise => { + const session = sessions.get(sessionId) + if (session) { + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) + } + const result = await stop() + try { + await closeConversation(sessionId) + } catch (error) { + if (!onCloseError) { + throw error + } + onCloseError(error) + } + return result + } + + const closeForWorkspaceSleep = ( + sessionId: string, + workspaceId: string, + executionHostId: ExecutionHostId + ): Promise<{ fence: number; stopped: boolean } | null> => { + const matches = (): boolean => { + const location = deps().store.getRecord(sessionId)?.location + return location?.workspaceId === workspaceId && location.executionHostId === executionHostId + } + // Pending creates do not yet have a record; their queued close waits for founding to finish. + if (matches()) { + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + } + return serialize(sessionId, async () => { + if (!matches()) { + return null + } + readRefusals.forget(sessionId) + const record = deps().store.getRecord(sessionId) + if (!record) { + return null + } + const fence = record.lease.runtimeFence + const session = sessions.get(sessionId) + const child = session?.child + const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true + const stopped = await holdStopClose( + sessionId, + () => + stopWorkspaceSleepChat({ + deps: deps(), + sessions, + stopAgent, + sessionId, + fence, + child, + owned, + now: () => host.context().now() + }), + (error) => + deps().logger.warn('closing a stopped workspace chat handle failed', { + scope: 'workspace-chat-close', + sessionId, + error + }) + ) + // Lease release advances the fence; preserve the value after this close for the caller's + // final identity check against a later restart. + return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } + }) + } + const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -137,6 +214,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) } return { + closeForWorkspaceSleep, idleSweep, stopAgent, /** A host stop of the child the expired attempt published, if it is still that one and still @@ -242,16 +320,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) - const session = sessions.get(sessionId) - if (session) { - // Settled and marked before the stop, so no start delivers it, and nothing it closed with - // sends by itself, even if the handle stays open. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { - mark: 'always' - }) - } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) - await closeConversation(sessionId) + await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 45ad97d2ec15..aa05c4ac120d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -55,7 +55,8 @@ export function createStructuredAgentSession( } throw error } - }) + }), + { sessionId: params.envelope.sessionId, location: params.location } ) } 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 61fef167631f..974fa2111ac6 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 @@ -189,7 +189,9 @@ export function createStructuredAgentSessionConversationDelivery(input: { // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { - if (sessions.has(sessionId)) { + const session = sessions.get(sessionId) + // A user close already settles and retires this handle; its next open re-derives any proof. + if (session && session.child?.close?.cause !== 'user-close') { void input .trackStart( input.serialize(sessionId, () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts new file mode 100644 index 000000000000..881a05ef78a3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts @@ -0,0 +1,53 @@ +import * as agentStart from './structured-agent-session-agent-start' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type * as conversation from './structured-agent-session-host-delivery' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' + +type Inputs = { + deps: StructuredAgentSessionHostDeps + sessions: StructuredAgentSessionConversations + subscribers: StructuredAgentSessionClientDelivery['subscribers'] + lifetime: StructuredAgentSessionConversationLifetime + clientDelivery: StructuredAgentSessionClientDelivery + conversationDelivery: conversation.StructuredAgentSessionConversationDelivery + queued: ReturnType + runtimeState: StructuredAgentSessionHostRuntimeState + attachContext: () => StructuredAgentSessionAttachContext + serialize: StructuredAgentSessionMutationContext['serialize'] + now: () => number +} + +export function createStructuredAgentSessionHostMutationContext( + input: Inputs +): StructuredAgentSessionMutationContext { + return { + deps: input.deps, + sessions: input.sessions, + publish: (sessionId, journal) => input.subscribers.publish(sessionId, journal), + conversation: input.lifetime.conversation, + readChildWork: input.clientDelivery.readChildWork, + publishChildWork: input.clientDelivery.publishChildWork, + captureChildWorkStops: input.clientDelivery.captureChildWorkStops, + childWorkOpen: input.clientDelivery.childWorkOpen, + childWorkIncomplete: input.clientDelivery.childWorkIncomplete, + serialize: input.serialize, + openConversation: input.conversationDelivery.open, + ensureAgent: (sessionId) => + agentStart.ensureStructuredAgentSessionAgentForOperation(input.attachContext(), sessionId), + startupAttempts: input.runtimeState.startupAttempts, + deliveryOrder: input.runtimeState.deliveryOrder, + joinChildClose: (sessionId) => + agentStart.joinClosingStructuredAgentSessionChild(input.attachContext(), sessionId), + wakeDelivery: (sessionId) => input.conversationDelivery.loop.wake(sessionId), + stopAgent: (sessionId, ending) => input.lifetime.stopAgent(sessionId, ending), + wakeQueuedDrain: (sessionId) => input.queued.drain.schedule(sessionId), + acquireAborts: input.runtimeState.acquireAborts, + now: input.now + } +} 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 0e9889fa0432..1bba744c55e5 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 @@ -7,7 +7,6 @@ import { StructuredConversationCommandController } from './structured-conversati // it, and the idle sweep is the one thing that puts it to rest. import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type * as SessionWire from '../../../shared/agent-session-wire' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -18,6 +17,7 @@ import { StructuredAgentSessionHostRuntimeState } from './structured-agent-sessi import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' +import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' import { createStructuredAgentSessionConversationLifetime, type StructuredAgentSessionConversationLifetime @@ -42,6 +42,7 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import * as conversation from './structured-agent-session-host-delivery' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' +import { sleepStructuredAgentSessionWorkspace } from './structured-agent-session-workspace-sleep' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -211,6 +212,20 @@ export class StructuredAgentSessionHost { close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) + /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ + sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => + sleepStructuredAgentSessionWorkspace( + { + deps: this.deps, + tasks: this.tasks, + lifetime: this.lifetime, + sessions: this.sessions, + deliveryRunning: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + childWorkIncomplete: this.clientDelivery.childWorkIncomplete + }, + workspaceId + ) + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -268,32 +283,20 @@ export class StructuredAgentSessionHost { }).finally(() => this.clientDelivery.closeAll()) } - private mutationContext(): StructuredAgentSessionMutationContext { - return { + private mutationContext = (): StructuredAgentSessionMutationContext => + createStructuredAgentSessionHostMutationContext({ deps: this.deps, sessions: this.sessions, - publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - conversation: this.lifetime.conversation, - readChildWork: this.clientDelivery.readChildWork, - publishChildWork: this.clientDelivery.publishChildWork, - captureChildWorkStops: this.clientDelivery.captureChildWorkStops, - childWorkOpen: this.clientDelivery.childWorkOpen, - childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + subscribers: this.subscribers, + lifetime: this.lifetime, + clientDelivery: this.clientDelivery, + conversationDelivery: this.conversationDelivery, + queued: this.queued, + runtimeState: this.runtimeState, + attachContext: () => this.attachContext(), serialize: (sessionId, task) => this.serialize(sessionId, task), - openConversation: this.conversationDelivery.open, - ensureAgent: (sessionId) => - agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - startupAttempts: this.runtimeState.startupAttempts, - deliveryOrder: this.runtimeState.deliveryOrder, - joinChildClose: (sessionId) => - agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), - wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), - acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() - } - } + }) send = this.conversationCommands.send @@ -320,8 +323,7 @@ export class StructuredAgentSessionHost { 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 => - structuredAgentSessionOwnerStatus(this.deps, sessionId) + handoffStatus = (sessionId: string) => structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request, scope) => this.backgroundTasks.history(request, scope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index e0eb83e0a0b5..539b1eb3cd06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -19,7 +19,7 @@ export function queuedSendAnswer( ? journal.submission(draft.consumedAs) : undefined return consumed - ? { clientMessageId, submission: consumed } + ? { clientMessageId, submission: { ...consumed } } : { clientMessageId, queued: { messageId: draft.messageId, position: draft.position, state: draft.state } @@ -38,5 +38,5 @@ export function queuedSendAnswer( queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } } } - return handoff ? { clientMessageId, submission: handoff } : null + return handoff ? { clientMessageId, submission: { ...handoff } } : null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 14767b058d1f..4d3c509f98f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -2,7 +2,11 @@ // module, so it must not pull in the host or Node-only process code. import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionRecord, + AgentSessionExecutionLocation, + AgentSessionProcessIdentity +} from '../../../shared/agent-session-record' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' @@ -42,3 +46,11 @@ export type StructuredAgentSessionStartupAttempt = { /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void } + +export type StructuredAgentSessionAcquisitionReceipt = Pick< + StructuredAgentSessionStartupAttempt, + 'attemptId' | 'fence' | 'spawnToken' +> & { + location: AgentSessionExecutionLocation + process?: AgentSessionProcessIdentity +} 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 index f0ec3c5dabc1..b82e1653d355 100644 --- 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 @@ -1,5 +1,6 @@ // 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. +// the legacy task list an older client folds from those same records. One chat's helpers are all +// recorded whatever their number, and its next accepted send retires every settled one. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -13,6 +14,7 @@ import type { } 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 { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed, @@ -352,3 +354,78 @@ describe('structured status summary child records', () => { ) }) }) + +describe('structured status feed over the real child-record store', () => { + it('records every helper of a busy chat, and its next accepted send removes every settled one', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: join(root, SESSION) + }) + const { sink, views } = acpChildWorkStatusSink() + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + getRecord: () => null, + now: () => 1_000, + statusSink: () => sink + }) + 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) + const live = (id: string, kind: 'agent' | 'command'): AgentChildWorkEvidence => ({ + type: 'live', + observedAt: 100, + child: { + handle: { idKind: 'task_id', id }, + kind, + residency: 'background', + state: 'working', + stoppable: true + } + }) + const finished = Array.from({ length: 2_049 }, (_, index) => `finished-${index}`) + feed.publishChildWork(SESSION, [ + ...finished.map((id) => live(id, 'agent')), + ...Array.from({ length: 40 }, (_, index) => live(`subagent-${index}`, 'agent')), + ...Array.from({ length: 300 }, (_, index) => live(`task-${index}`, 'command')) + ]) + feed.publishChildWork( + SESSION, + finished.map((id): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt: 200, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + })) + ) + expect(views()).toHaveLength(2_389) + expect(views().filter((view) => view.membership === 'settled')).toHaveLength(2_049) + expect(feed.childWorkIncomplete(SESSION)).toBe(false) + + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'accepted', + providerIdentity: { ...USER_IDENTITY, ordinal: USER_IDENTITY.ordinal + 1 }, + fence: 1 + }) + feed.publish(SESSION) + expect(views()).toHaveLength(340) + expect(views().every((view) => view.membership === 'live')).toBe(true) + expect(feed.childWorkIncomplete(SESSION)).toBe(false) + }, 60_000) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 03fba788aabf..31f7ab677d4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -5,7 +5,6 @@ import type { AgentChildWorkEvidence, AgentChildWorkLiveEvidence } from '../../../shared/agent-status-child-work-evidence' -import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { AgentHookServer } from '../../agent-hooks/server' import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' @@ -161,23 +160,21 @@ describe('child work the provider reported, recorded or not', () => { outcome: 'succeeded' }) - /** Another session holds every child record the store has room for; idle parent beside it. */ - function storeFull() { - const { owner } = owners() - owner.publish({ ...summary, sessionId: 'structured-busy' }, location) - owner.publishChildWork( - 'structured-busy', - Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => - agentLive(`busy-${index}`) - ), - 'claude' - ) + /** An idle parent whose child records the store refuses until `admit`. */ + function refusingStore() { + let refusing = true + const { owner } = owners(() => refusing) owner.publish({ ...summary, status: 'idle' }, location) - return owner + return { + owner, + admit: () => { + refusing = false + } + } } it('marks an idle parent incomplete when the store refuses its live background child', () => { - const owner = storeFull() + const { owner } = refusingStore() owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') expect(owner.readChildWork(summary.sessionId)).toEqual([]) @@ -194,18 +191,17 @@ describe('child work the provider reported, recorded or not', () => { }) it('is whole again once the refused child is admitted on a later report', () => { - const owner = storeFull() + const { owner, admit } = refusingStore() owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) - // The other session goes, and with it the records that filled the store. - owner.forget('structured-busy') + admit() owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') expect(owner.readChildWork(summary.sessionId)).toHaveLength(1) expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) }) it('is whole again once every refused child reports its own end', () => { - const owner = storeFull() + const { owner } = refusingStore() owner.publishChildWork(summary.sessionId, [agentLive('first'), agentLive('second')], 'claude') owner.publishChildWork(summary.sessionId, [ended('first')], 'claude') expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) @@ -214,19 +210,6 @@ describe('child work the provider reported, recorded or not', () => { expect(owner.childWorkOpen(summary.sessionId)).toBe(false) }) - it('marks a parent the store refused incomplete, since none of its children is recorded', () => { - const { owner } = owners() - for (let index = 0; index < AGENT_STATUS_STORE_LIMITS.parents; index++) { - owner.publish({ ...summary, sessionId: `structured-other-${index}` }, location) - } - owner.publish({ ...summary, status: 'idle' }, location) - owner.publishChildWork(summary.sessionId, [agentLive('background-agent')], 'claude') - expect(owner.readChildWork(summary.sessionId)).toEqual([]) - expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) - owner.publishChildWork(summary.sessionId, [ended('background-agent')], 'claude') - expect(owner.childWorkIncomplete(summary.sessionId)).toBe(false) - }) - // Claude names the run a task ended on, and a resumed agent's earlier run may end late. it('holds recorded work open, never incomplete, through a late ending of its earlier run', () => { const { owner } = owners() 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 3dd914f647fa..0d3144dd76fe 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 @@ -45,6 +45,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' +const REQUESTED_CLOSE_END = { type: 'ended', reason: 'closed', cause: 'requested-close' } as const const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -238,7 +239,19 @@ beforeEach(async () => { } } }) - closeSession = vi.fn(async () => true) + // Like real adapters, the close reports its own end onto the chat's lane before it returns. + closeSession = vi.fn(async (sessionId: string) => { + const child = host['sessions'].get(sessionId)?.child + if (child?.generation) { + void host.handleAdapterEvent({ + ...REQUESTED_CLOSE_END, + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation + }) + } + return true + }) dispatch = vi.fn(async () => ({ state: 'rejected' as const, ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { @@ -255,6 +268,45 @@ afterEach(async () => { }) describe('a chat that closes', () => { + it('returns the receipt for a stopped child whose provider reports its own end', async () => { + await attach() + await host['tasks'].drainAttaches() + const reported = vi.spyOn(host, 'handleAdapterEvent') + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) + // History stays saved while the conversation handle closes. + expect([store.getRecord(SESSION) !== null, host.hasSession(SESSION)]).toEqual([true, false]) + expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) + }) + + it('omits an unproven workspace child from the stop receipt', async () => { + await attach() + closeSession.mockResolvedValueOnce(false) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] + }) + expect(store.getRecord(SESSION)).not.toBeNull() + }) + + it('does not count a chat whose send was accepted after the stop as stopped', async () => { + await attach() + await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const result = await close(...args) + const body = hostTestMessage('newer send') + // The later work exists once the send is accepted, not merely once it is queued. + const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(await sent).toMatchObject({ ok: true }) + return result + }) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + }) + it('stops the provider child it started', async () => { await attach() @@ -372,7 +424,7 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { + it('keeps a running turn and sleeps only proven ended chats after idle eviction or restart', async () => { await attach() emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) @@ -388,6 +440,17 @@ describe('a session with a turn in flight', () => { clock += IDLE_MS await waitForEviction() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + await abandonStructuredAgentSessionHost(host) + await reboot() + await host.restoreReadableSessions() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + const getRecord = store.getRecord.bind(store) + const withoutDeath = (record: ReturnType) => + record && { ...record, lease: { ...record.lease, deathEvidence: null } } + vi.spyOn(store, 'getRecord').mockImplementation((id) => withoutDeath(getRecord(id))) + const receipt = await host.sleepWorkspace('workspace-1') + expect(receipt).toMatchObject({ failures: [{ reason: 'stop-unverifiable' }] }) }) // Codex settles an admitted send only on its echo, which may never come; the stop retires it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts index f0237835c756..79c6234a47f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -1,22 +1,55 @@ import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { private readonly chains = new Map>() private readonly attaching = new Set>() + private readonly pendingLocations = new Map< + Promise, + { + sessionId: string + location: AgentSessionExecutionLocation + } + >() serialize(sessionId: string, task: () => Promise): Promise { return runKeyedSerializedOperation(this.chains, sessionId, task) } - trackAttach(operation: Promise): Promise { + trackAttach( + operation: Promise, + pending?: { sessionId: string; location: AgentSessionExecutionLocation } + ): Promise { this.attaching.add(operation) + if (pending) { + this.pendingLocations.set(operation, pending) + } void operation.then( - () => this.attaching.delete(operation), - () => this.attaching.delete(operation) + () => this.finishAttach(operation), + () => this.finishAttach(operation) ) return operation } + private finishAttach(operation: Promise): void { + this.attaching.delete(operation) + this.pendingLocations.delete(operation) + } + + pendingMembers(workspaceId: string, executionHostId: string): string[] { + return [...this.pendingLocations.values()] + .filter( + ({ location }) => + location.workspaceId === workspaceId && location.executionHostId === executionHostId + ) + .map(({ sessionId }) => sessionId) + } + + /** A create or attach for this session that has not settled yet. */ + hasPendingAttach(sessionId: string): boolean { + return [...this.pendingLocations.values()].some((pending) => pending.sessionId === sessionId) + } + async drainAttaches(): Promise { while (this.attaching.size > 0) { await Promise.allSettled(this.attaching) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index f5258f0e5a87..ac9dd8baf42c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -209,7 +209,7 @@ export async function performSend( ok: true, value: { clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) + submission: { ...requireSubmission(ctx, input.clientMessageId) } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts new file mode 100644 index 000000000000..5b2d81155225 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -0,0 +1,165 @@ +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + adapterAtPublishFor, + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { expect, it, vi } from 'vitest' +import { hostTestMessage } from './structured-agent-session-host-test-data' +import { + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState, + attachParams +} from './structured-agent-session-host-test-harness' + +it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { + const { host, store, acquire } = hostTestState() + const record = await attach() + if (!record) { + throw new Error('attach did not produce a record') + } + const sibling = 'session-beta' + const siblingRecord = { ...record, sessionId: sibling } + const getRecord = store.getRecord.bind(store) + vi.spyOn(store, 'listRecords').mockReturnValue([record, siblingRecord]) + vi.spyOn(store, 'getRecord').mockImplementation((id) => + id === sibling ? siblingRecord : getRecord(id) + ) + host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + let failedStopDone: () => void = () => undefined + const failedStop = new Promise((resolve) => { + failedStopDone = resolve + }) + let releaseSibling: () => void = () => undefined + const slowSibling = new Promise((resolve) => { + releaseSibling = resolve + }) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + if (args[0] === sibling) { + await slowSibling + return { fence: siblingRecord.lease.runtimeFence, stopped: true } + } + const result = await close(...args) + failedStopDone() + return result + }) + + const sleeping = host.sleepWorkspace('workspace-1') + try { + await failedStop + const body = hostTestMessage('resume after unverified stop') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) + } finally { + releaseSibling() + } + expect(await sleeping).toEqual({ stoppedSessionIds: [sibling] }) +}) + +it.each(['persist', 'root-exit', 'retry', 'successor', 'root-successor'] as const)( + 'retains failed-acquisition exit proof across %s', + async (mode) => { + const { host: original, store, root } = hostTestState() + const claude = fakeClaude({ + unprovenCloseVerdict: { root: 'unverifiable', tree: 'unverifiable' } + }) + const open = claude.openConnection + claude.openConnection = async (...args) => { + const connection = await open(...args) + claude.connections[0].closed = true + return connection + } + const adapter = adapterAtPublishFor(claude) + const agents = claudeAndCodexAgents(adapter) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const host = new StructuredAgentSessionHost({ ...original.deps, agents, adapter: router }) + replaceHostTestState({ host, store }) + await expect( + host.attach( + CALLER, + attachParams({ + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(router.hasOwnedAcquisition('session-alpha')).toBe(true) + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + const connection = claude.connections[0] + connection.close = vi.fn(async () => mode !== 'root-exit' && mode !== 'root-successor') + if (mode === 'root-exit' || mode === 'root-successor') { + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + } + if (mode === 'retry' || mode === 'successor') { + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('evidence write failed')) + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + if (mode === 'retry' || mode === 'successor') { + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + expect(router.stoppedAcquisition('session-alpha')).toBeDefined() + } + if (mode === 'successor' || mode === 'root-successor') { + const body = hostTestMessage('send after failed exit bookkeeping') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi + .waitFor(() => expect(claude.connections).toHaveLength(2)) + .finally(() => { + for (const opened of claude.connections) { + opened.close = async () => true + } + }) + claude.connections[1].close = async () => true + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + return + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + expect(connection.close).toHaveBeenCalledOnce() + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + const reopened = await openTestAgentSessionRecordStore(root) + const restarted = new StructuredAgentSessionHost({ ...host.deps, store: reopened }) + expect(reopened.getRecord('session-alpha')?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + ownerFence: 1 + }) + await restarted.restoreReadableSessions() + expect(await restarted.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + } +) + +it('keeps a send admitted after the host Sleep close alive', async () => { + const { host, acquire, dispatch } = hostTestState() + await attach() + const close = vi.fn(async () => true) + host.deps.adapter.closeSession = close + const sleeping = host.sleepWorkspace('workspace-1') + const body = hostTestMessage('phone send after Sleep admission') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await sleeping + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + expect(host.hasSession('session-alpha')).toBe(true) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts new file mode 100644 index 000000000000..cb7b9a43f54d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -0,0 +1,83 @@ +import { recordAgentSessionAcquisitionExit } from '../../runtime/agent-session-acquisition-exit-transition' +import { + agentSessionLeaseIsReleased, + agentSessionLeaseOwnerVerdict +} from '../../../shared/agent-session-lease-adjudication' +import { structuredSessionNeverStarted } from '../../runtime/agent-session-record-founding' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' + +/** Stops one chat captured by a workspace Sleep and reports whether the stop is proven. */ +export async function stopWorkspaceSleepChat(input: { + deps: Pick + sessions: StructuredAgentSessionConversations + stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise + sessionId: string + fence: number + child: StructuredAgentSessionProviderChild | null | undefined + owned: boolean + now: () => number +}): Promise { + const { deps, sessions, sessionId, child } = input + let ownedStopped = false + try { + if (child) { + await input.stopAgent(sessionId, { cause: 'user-close' }) + } else if (input.owned) { + ownedStopped = await stopAgentSessionProviderRoot( + () => deps.adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) + ) + const receipt = deps.adapter.stoppedAcquisition?.(sessionId) + if (ownedStopped && receipt) { + try { + await deps.store.transitionHandoff(sessionId, (record) => { + if ( + deps.adapter.stoppedAcquisition?.(sessionId) !== receipt || + sessions.get(sessionId)?.child + ) { + throw new Error('agent_session_ownership_unknown') + } + return recordAgentSessionAcquisitionExit(record, receipt, input.fence, input.now()) + }) + deps.adapter.acknowledgeSessionRelease?.(sessionId, receipt) + } catch (error) { + deps.logger.warn('saving failed acquisition exit evidence failed', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + } + } + } catch (error) { + deps.logger.warn('workspace chat stop was not proven', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + const ended = sessions.get(sessionId)?.lastEndedChild + const after = deps.store.getRecord(sessionId) + const childStopped = + !!child && + !sessions.get(sessionId)?.child && + ended?.rootGone === true && + sameProviderChild(ended, child) + // A released reservation may still have an unproven root after restart; only + // exit evidence or a never-started record proves an unheld chat is stopped. + const alreadyStopped = + !!after && + after.lease.runtimeFence === input.fence && + agentSessionLeaseIsReleased(after.lease) && + (agentSessionLeaseOwnerVerdict(after.lease) === 'exited' || + structuredSessionNeverStarted(after)) && + !sessions.get(sessionId)?.child && + deps.adapter.hasOwnedAcquisition?.(sessionId) !== true + return childStopped || ownedStopped || alreadyStopped +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..5ebbe337fcf3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -0,0 +1,90 @@ +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionWorkspaceSleepResult } from '../../../shared/agent-session-workspace-sleep' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +type WorkspaceSleepHost = { + deps: Pick + tasks: StructuredAgentSessionTaskQueue + lifetime: Pick + sessions: StructuredAgentSessionConversations + deliveryRunning: (sessionId: string) => boolean + childWorkIncomplete: (sessionId: string) => boolean +} + +/** Snapshot host-owned members and return only same-call, current positive stop receipts. */ +export async function sleepStructuredAgentSessionWorkspace( + host: WorkspaceSleepHost, + workspaceId: string +): Promise { + let records: ReturnType + try { + records = host.deps.store.listRecords() + } catch (error) { + host.deps.logger.warn('workspace chat inventory failed', { + scope: 'workspace-chat-inventory', + workspaceId, + error + }) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + const belongs = (location: AgentSessionExecutionLocation): boolean => + location.workspaceId === workspaceId && location.executionHostId === LOCAL_EXECUTION_HOST_ID + const candidates = new Set([ + ...records.filter((record) => belongs(record.location)).map((record) => record.sessionId), + ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) + ]) + const sessionIds = [...candidates] + // Admits every close synchronously; `async` turns a synchronous throw into a rejection. + const stops = await Promise.allSettled( + sessionIds.map(async (sessionId) => + host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + ) + ) + const stoppedSessionIds: string[] = [] + const failures: NonNullable = [] + for (const [index, stop] of stops.entries()) { + const sessionId = sessionIds[index] + if (stop.status === 'rejected') { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + if (!stop.value) { + continue + } + try { + const record = host.deps.store.getRecord(sessionId) + if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { + continue + } + if (!stop.value.stopped) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + // Judge later work by facts, not lane occupancy: the stop queues its own end report. + const session = host.sessions.get(sessionId) + if ( + session?.child || + host.tasks.hasPendingAttach(sessionId) || + host.deliveryRunning(sessionId) || + host.childWorkIncomplete(sessionId) || + session?.journal.pendingSubmissions().length || + host.deps.hasOpenDispatch?.(record) + ) { + continue + } + stoppedSessionIds.push(sessionId) + } catch (error) { + host.deps.logger.warn('workspace chat stop receipt could not be checked', { + scope: 'workspace-chat-receipt', + sessionId, + error + }) + failures.push({ sessionId, reason: 'stop-unverifiable' }) + } + } + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } +} diff --git a/src/main/native-chat/codex-adoption-preparation.ts b/src/main/native-chat/codex-adoption-preparation.ts index 0e86904abdd9..8ce69292a0f4 100644 --- a/src/main/native-chat/codex-adoption-preparation.ts +++ b/src/main/native-chat/codex-adoption-preparation.ts @@ -21,13 +21,16 @@ export async function prepareCodexAdoption( return source } const nativeSystemHomePath = getSystemCodexHomePath() - const result = await prepare({ - agent: 'codex', - sessionId: providerSessionId, - filePath: source.transcriptPath, - codexHome: source.accountHome.path, - executionHostId: 'local' - }, nativeSystemHomePath) + const result = await prepare( + { + agent: 'codex', + sessionId: providerSessionId, + filePath: source.transcriptPath, + codexHome: source.accountHome.path, + executionHostId: 'local' + }, + nativeSystemHomePath + ) const home = result.useRealCodexHome ? nativeSystemHomePath : result.substituteCodexHome if ( !home || @@ -41,7 +44,8 @@ export async function prepareCodexAdoption( !recognizedHomes.some( (candidate) => isLegacyAgentSessionAccountHome(candidate) && - normalizeRuntimePathForComparison(candidate.path) === normalizeRuntimePathForComparison(home) + normalizeRuntimePathForComparison(candidate.path) === + normalizeRuntimePathForComparison(home) ) ) { throw new Error('The prepared conversation account is not recognized on this host.') diff --git a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index 38962a1602fb..1a7f5e754161 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -129,9 +129,12 @@ describe('inline visuals launch preference', () => { expect(disabled.calls.slice(0, openIndex).map(({ method }) => method)).not.toContain( 'config/read' ) - expect( - disabled.calls.find(({ method }) => method === 'thread/start')?.params - ).not.toHaveProperty('config') + const disabledOpen = disabled.calls.find(({ method }) => method === 'thread/start') + const scratchDirOverride = `shell_environment_policy.set.${NATIVE_CHAT_SCRATCH_DIR_ENV}` + expect(disabledOpen?.params?.config).not.toHaveProperty([ + 'sandbox_workspace_write.writable_roots' + ]) + expect(disabledOpen?.params?.config).not.toHaveProperty([scratchDirOverride]) expect(existsSync(nativeChatScratchFolderFor(stateDirectory, 'disabled'))).toBe(false) expect(first.closed).toBe(false) expect(first.calls).toEqual(firstCalls) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 49d4ab5869e6..435e8bf0fe00 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -224,7 +224,12 @@ async function resolveTiered( agent: SessionIdLookupAgent, sessionId: string, hostDirs: readonly string[], - options: { wslTier: boolean; accountDirs?: () => readonly string[]; signal?: AbortSignal; strictErrors?: boolean } + options: { + wslTier: boolean + accountDirs?: () => readonly string[] + signal?: AbortSignal + strictErrors?: boolean + } ): Promise { const { signal } = options const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal, options.strictErrors) @@ -232,7 +237,13 @@ async function resolveTiered( return hit } const accountDirs = (options.accountDirs?.() ?? []).filter((dir) => !hostDirs.includes(dir)) - const accountHit = await findFirstInDirs(agent, sessionId, accountDirs, signal, options.strictErrors) + const accountHit = await findFirstInDirs( + agent, + sessionId, + accountDirs, + signal, + options.strictErrors + ) if (accountHit || !options.wslTier) { return accountHit } diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index c56e77add999..864d52d87e73 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -53,7 +53,10 @@ export function absoluteConversationStore(path: string | null | undefined): stri } export function directoryTranscriptImport(directories: { - conversationStore: (input: { accountHomePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null + conversationStore: (input: { + accountHomePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null accountHomeCandidates: (input: { settings: StructuredAgentTranscriptImportSettings selectedAccountHomePath: string diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index 79d05fa69140..988ecc7818fa 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -503,7 +503,9 @@ describe('readOpenCodeTranscriptSignal', () => { insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } }) insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } }) - db.exec("ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'") + db.exec( + "ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'" + ) const signal = readOpenCodeTranscriptSignal(path, 'ses-1') expect(signal).toEqual({ cwd: '/folder', diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 58593c368481..7265f9a00097 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -62,7 +62,10 @@ describe('bundle โ€” collection', () => { { kind: 'empty', names: [] }, { kind: 'ASCII', names: ['plain'] }, { kind: 'Unicode', names: ['ๆผขๅญ—๐Ÿ™‚', '\ud800'] }, - { kind: 'capped', names: Array.from({ length: 600 }, () => 'ๆผขๅญ—๐Ÿ™‚'.repeat(1000)) } + { + kind: 'capped', + names: Array.from({ length: 600 }, () => 'ๆผขๅญ—๐Ÿ™‚'.repeat(1000)) + } ])('reports the exact UTF-8 payload size for $kind records', ({ names }) => { writeFileSync(traceFile, makeNDJSON(names.map((name) => makeSpan({ name })))) const bundle = collectBundle({ @@ -165,7 +168,12 @@ describe('bundle โ€” collection', () => { writeFileSync( daemonFile, makeNDJSON([ - { src: 'daemon', ts: new Date().toISOString(), pid: 1, event: 'startup' }, + { + src: 'daemon', + ts: new Date().toISOString(), + pid: 1, + event: 'startup' + }, { src: 'daemon', // 1h ago โ€” outside the 30m window, must be dropped. @@ -192,6 +200,121 @@ describe('bundle โ€” collection', () => { expect(bundle.payload).not.toContain('"event":"session-exited"') }) + it('keeps daemon log lines when trace spans alone would fill the cap', () => { + const daemonFile = join(dir, 'daemon.log') + const filler = 'x'.repeat(4096) + writeFileSync( + traceFile, + makeNDJSON(Array.from({ length: 1500 }, () => makeSpan({ name: 'git.exec', filler }))) + ) + writeFileSync( + daemonFile, + makeNDJSON([ + { + src: 'daemon', + ts: new Date().toISOString(), + pid: 1, + event: 'session-created' + } + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + appVersion: '1', + platform: 'win32', + arch: 'x64', + osRelease: 'test', + orcaChannel: 'stable' + }) + expect(bundle.payload).toContain('"event":"session-created"') + expect(bundle.payload).toContain('"name":"git.exec"') + expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload)) + expect(bundle.bytes).toBeLessThanOrEqual(_internalsForTests.MAX_BUNDLE_BYTES) + // An almost-empty daemon log leaves the rest of the cap to traces. + expect(bundle.bytes).toBeGreaterThan(_internalsForTests.MAX_BUNDLE_BYTES - 8192) + }) + + it('gives the daemon log whatever quiet traces leave unused', () => { + const daemonFile = join(dir, 'daemon.log') + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + const detail = 'd'.repeat(1024) + writeFileSync( + daemonFile, + makeNDJSON( + Array.from({ length: 3000 }, (_, index) => ({ + src: 'daemon', + ts: new Date().toISOString(), + pid: 1, + event: `session-attached-${index}`, + detail + })) + ) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + appVersion: '1', + platform: 'win32', + arch: 'x64', + osRelease: 'test', + orcaChannel: 'stable' + }) + expect(bundle.payload).toContain('"name":"recent"') + // All ~3 MiB of daemon lines fit beside one span; the share is a floor, not a cap. + expect(bundle.payload).toContain('"event":"session-attached-0"') + expect(bundle.payload).toContain('"event":"session-attached-2999"') + expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload)) + expect(bundle.bytes).toBeLessThanOrEqual(_internalsForTests.MAX_BUNDLE_BYTES) + }) + + it('keeps the newest daemon share when both sources overflow the cap', () => { + const daemonFile = join(dir, 'daemon.log') + const filler = 'x'.repeat(4096) + writeFileSync( + traceFile, + makeNDJSON(Array.from({ length: 1500 }, () => makeSpan({ name: 'git.exec', filler }))) + ) + const detail = 'd'.repeat(1024) + writeFileSync( + daemonFile, + makeNDJSON( + Array.from({ length: 6000 }, (_, index) => ({ + src: 'daemon', + ts: new Date().toISOString(), + pid: 1, + event: `session-attached-${index}`, + detail + })) + ) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + appVersion: '1', + platform: 'win32', + arch: 'x64', + osRelease: 'test', + orcaChannel: 'stable' + }) + const daemonBytes = bundle.payload + .split('\n') + .filter((line) => line.includes('"src":"daemon"')) + .reduce((total, line) => total + Buffer.byteLength(line) + 1, 0) + expect(bundle.payload).toContain('"name":"git.exec"') + expect(bundle.payload).toContain('"event":"session-attached-5999"') + expect(daemonBytes).toBeGreaterThan(_internalsForTests.DAEMON_LOG_BUDGET_BYTES - 4096) + // Plus the sliver spans could not use: less than one 4 KiB span record. + expect(daemonBytes).toBeLessThan(_internalsForTests.DAEMON_LOG_BUDGET_BYTES + 8192) + expect(bundle.bytes).toBeLessThanOrEqual(_internalsForTests.MAX_BUNDLE_BYTES) + }) + it('collects no daemon log lines when no daemon log path is given', () => { writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) const bundle = collectBundle({ @@ -288,7 +411,10 @@ describe('bundle โ€” collection', () => { name: 'oldest', attributes: { message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) } }) - const newSpan = makeSpan({ name: 'newest', attributes: { message: 'recent crash' } }) + const newSpan = makeSpan({ + name: 'newest', + attributes: { message: 'recent crash' } + }) writeFileSync(traceFile, makeNDJSON([oldSpan, newSpan])) const bundle = collectBundle({ traceFilePath: traceFile, @@ -327,12 +453,18 @@ describe('bundle โ€” collection', () => { }) it('skips oversized middle spans after accepting newer context', () => { - const olderUseful = makeSpan({ name: 'older-useful', attributes: { message: 'older context' } }) + const olderUseful = makeSpan({ + name: 'older-useful', + attributes: { message: 'older context' } + }) const oversizedMiddle = makeSpan({ name: 'oversized-middle', attributes: { message: 'x'.repeat(_internalsForTests.MAX_BUNDLE_BYTES) } }) - const newestUseful = makeSpan({ name: 'newest-useful', attributes: { message: 'new context' } }) + const newestUseful = makeSpan({ + name: 'newest-useful', + attributes: { message: 'new context' } + }) writeFileSync(traceFile, makeNDJSON([olderUseful, oversizedMiddle, newestUseful])) const bundle = collectBundle({ traceFilePath: traceFile, @@ -595,7 +727,10 @@ describe('uploadBundle and deleteBundle', () => { res.setHeader('content-type', 'application/json') res.end('{}') }) - await deleteBundle({ tokenEndpoint: `${baseUrl}/diagnostics/token`, ticketId }) + await deleteBundle({ + tokenEndpoint: `${baseUrl}/diagnostics/token`, + ticketId + }) expect(seen).toEqual([`/diagnostics/delete/${ticketId}`]) }) }) diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 5e841a4d37e9..884a3cc2c004 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -13,6 +13,8 @@ import { listRotatedFiles } from './local-file-sink' import { redactValue } from './redactor' const DEFAULT_LOOKBACK_MINUTES = 30 +/** The daemon log's reserved share of the bundle; whatever it leaves unused goes to trace spans. */ +const DAEMON_LOG_BUDGET_BYTES = 1024 * 1024 export type CollectBundleOptions = { readonly traceFilePath: string @@ -66,41 +68,22 @@ function* readLinesNewestFirst(text: string): Iterable { } } -/** - * Read the last N minutes of NDJSON across the rotated family into a redacted bundle payload. - * Main keeps the uploadable payload so a compromised renderer can't substitute bytes after preview. - */ -export function collectBundle(opts: CollectBundleOptions): CollectedBundle { - const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 - const cutoffMs = Date.now() - lookbackMs - const cutoffNanos = BigInt(cutoffMs) * 1_000_000n - const bundleSubmissionId = generateBundleSubmissionId() - const header: BundleHeader = { - bundle_submission_id: bundleSubmissionId, - app_version: opts.appVersion, - platform: opts.platform, - arch: opts.arch, - os_release: opts.osRelease, - orca_channel: opts.orcaChannel, - collected_at: new Date().toISOString(), - schema_version: 1 - } +type RecordWindow = { + readonly cutoffMs: number + readonly cutoffNanos: bigint + readonly maxRecordBytes: number +} - const headerLine = JSON.stringify({ type: 'bundle-header', ...header }) - const lines: string[] = [headerLine] - let spanCount = 0 +/** Redacted records newest โ†’ oldest across `files`, stopping at `budgetBytes`. */ +function collectNewestRecords( + files: readonly string[], + budgetBytes: number, + recordWindow: RecordWindow +): { lines: string[]; bytes: number } { + const lines: string[] = [] // Track bytes incrementally to avoid an O(Nยฒ) `lines.join('\n').length` per span. - let currentBytes = Buffer.byteLength(`${headerLine}\n`) - const maxRecordBytes = MAX_BUNDLE_BYTES - currentBytes - - // Trace files then daemon log, each newest โ†’ oldest so the byte cap keeps the most recent spans. - const files = [ - ...listRotatedFiles(opts.traceFilePath, opts.maxFiles), - ...(opts.daemonLogFilePath - ? listRotatedFiles(opts.daemonLogFilePath, opts.daemonLogMaxFiles ?? opts.maxFiles) - : []) - ] - outer: for (const file of files) { + let bytes = 0 + for (const file of files) { let text: string try { // stat first: the sink caps at 10 MB/file, so a tampered oversize file could panic-allocate on read. @@ -132,7 +115,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // Filter by end-time, not start-time, so long-lived spans that ended inside the lookback are still included. if (typeof record.endTimeUnixNano === 'string') { try { - if (BigInt(record.endTimeUnixNano) < cutoffNanos) { + if (BigInt(record.endTimeUnixNano) < recordWindow.cutoffNanos) { continue } } catch { @@ -141,7 +124,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { } else if (typeof record.ts === 'string') { // Daemon lifecycle lines use an ISO `ts`; unparseable timestamps are kept (over-include). const tsMs = Date.parse(record.ts) - if (Number.isFinite(tsMs) && tsMs < cutoffMs) { + if (Number.isFinite(tsMs) && tsMs < recordWindow.cutoffMs) { continue } } @@ -149,19 +132,89 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // Second redaction pass (server mode) catches nested auth fields and strips identity keys before preview. const redacted = JSON.stringify(redactValue(parsed, 'server')) const redactedBytes = Buffer.byteLength(redacted) + 1 - if (redactedBytes > maxRecordBytes) { + if (redactedBytes > recordWindow.maxRecordBytes) { // Skip a single oversized record so it can't suppress every smaller span behind it. continue } - if (currentBytes + redactedBytes > MAX_BUNDLE_BYTES) { + if (bytes + redactedBytes > budgetBytes) { // Hard ceiling matches the upload endpoint's 4 MiB; check before appending so the preview uploads as-is. - break outer + return { lines, bytes } } lines.push(redacted) - spanCount += 1 - currentBytes += redactedBytes + bytes += redactedBytes + } + } + return { lines, bytes } +} + +/** The newest `lines` (already newest โ†’ oldest) that fit `budgetBytes`, one newline each. */ +function takeNewestLines( + lines: readonly string[], + budgetBytes: number +): { lines: string[]; bytes: number } { + const taken: string[] = [] + let bytes = 0 + for (const line of lines) { + const lineBytes = Buffer.byteLength(line) + 1 + if (bytes + lineBytes > budgetBytes) { + break } + taken.push(line) + bytes += lineBytes } + return { lines: taken, bytes } +} + +/** + * Read the last N minutes of NDJSON across the rotated family into a redacted bundle payload. + * Main keeps the uploadable payload so a compromised renderer can't substitute bytes after preview. + */ +export function collectBundle(opts: CollectBundleOptions): CollectedBundle { + const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 + const cutoffMs = Date.now() - lookbackMs + const cutoffNanos = BigInt(cutoffMs) * 1_000_000n + const bundleSubmissionId = generateBundleSubmissionId() + const header: BundleHeader = { + bundle_submission_id: bundleSubmissionId, + app_version: opts.appVersion, + platform: opts.platform, + arch: opts.arch, + os_release: opts.osRelease, + orca_channel: opts.orcaChannel, + collected_at: new Date().toISOString(), + schema_version: 1 + } + + const headerLine = JSON.stringify({ type: 'bundle-header', ...header }) + const headerBytes = Buffer.byteLength(`${headerLine}\n`) + const recordWindow = { + cutoffMs, + cutoffNanos, + maxRecordBytes: MAX_BUNDLE_BYTES - headerBytes + } + + // Why the daemon log goes first with its own share: trace spans alone fill the cap on a + // busy host, which dropped every daemon line from most field OOM bundles. + const available = MAX_BUNDLE_BYTES - headerBytes + // Collect the daemon log up to the whole bundle, reserve its newest share before spans, + // then hand it back whatever spans leave unused, so a quiet trace never caps it at the share. + const daemonAll = opts.daemonLogFilePath + ? collectNewestRecords( + listRotatedFiles(opts.daemonLogFilePath, opts.daemonLogMaxFiles ?? opts.maxFiles), + available, + recordWindow + ) + : { lines: [], bytes: 0 } + const reserved = takeNewestLines(daemonAll.lines, Math.min(DAEMON_LOG_BUDGET_BYTES, available)) + const traces = collectNewestRecords( + listRotatedFiles(opts.traceFilePath, opts.maxFiles), + available - reserved.bytes, + recordWindow + ) + const daemon = takeNewestLines(daemonAll.lines, available - traces.bytes) + const lines = [headerLine, ...traces.lines, ...daemon.lines] + const spanCount = traces.lines.length + daemon.lines.length + const currentBytes = headerBytes + traces.bytes + daemon.bytes const payload = `${lines.join('\n')}\n` return { @@ -186,5 +239,6 @@ export function generateBundleSubmissionId(): string { // Test-only export. export const _internalsForTests = { - MAX_BUNDLE_BYTES + MAX_BUNDLE_BYTES, + DAEMON_LOG_BUDGET_BYTES } diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts index 691275f09fcb..b2fbbdbcb96c 100644 --- a/src/main/omp/omp-session-cwd-lookup.test.ts +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -52,7 +52,12 @@ async function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = j launchEnv }) return store - ? OMP_TRANSCRIPT_IMPORT.findTranscript({ providerSessionId: ID, store, workspaceCwd: cwd, launchEnv }) + ? OMP_TRANSCRIPT_IMPORT.findTranscript({ + providerSessionId: ID, + store, + workspaceCwd: cwd, + launchEnv + }) : null } diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index 357d0efd1c67..ae7ebacb7cdb 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -40,10 +40,10 @@ class RuntimeChild extends EventEmitter { let child: RuntimeChild const signalNames = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const -let oldListeners: Map> +let oldListeners: Map beforeEach(() => { - oldListeners = new Map(signalNames.map((signal) => [signal, process.rawListeners(signal)])) + oldListeners = new Map(signalNames.map((signal) => [signal, process.listeners(signal)])) child = new RuntimeChild() fixture.exists.mockReturnValue(true) fixture.read.mockImplementation((path) => @@ -61,7 +61,7 @@ beforeEach(() => { afterEach(() => { for (const signal of signalNames) { - for (const listener of process.rawListeners(signal)) { + for (const listener of process.listeners(signal)) { if (!oldListeners.get(signal)?.includes(listener)) { process.off(signal, listener) } @@ -165,7 +165,7 @@ describe('bundled Orca runtime handoff', () => { }) for (const signal of signalNames) { const listener = process - .rawListeners(signal) + .listeners(signal) .find((candidate) => !oldListeners.get(signal)?.includes(candidate)) if (signal === 'SIGHUP' && platform === 'win32') { expect(listener).toBeUndefined() @@ -193,7 +193,7 @@ describe('bundled Orca runtime handoff', () => { expect(process.exit).toHaveBeenCalledWith(23) expect(process.kill).not.toHaveBeenCalled() for (const signal of signalNames) { - expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + expect(process.listeners(signal)).toEqual(oldListeners.get(signal)) } }) @@ -222,7 +222,7 @@ describe('bundled Orca runtime handoff', () => { expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit') expect(process.exit).toHaveBeenCalledWith(78) for (const signal of signalNames) { - expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + expect(process.listeners(signal)).toEqual(oldListeners.get(signal)) } }) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 424c32d84954..cac21a57beca 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -314,6 +314,7 @@ async function startOrcadRuntime( // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. observedStatusCapture.attach(runtime) + agentHookServer.setCliWriterExecutionMatcher(observedPaneIdentities, runtime) // Why before the RPC server binds: like `--serve`, the first client must find a ready graph. const { publishHeadlessRuntimeGraph } = await import('../runtime/headless-runtime-graph') publishHeadlessRuntimeGraph(runtime) diff --git a/src/main/orcad/orcad-host-unavailable.ts b/src/main/orcad/orcad-host-unavailable.ts index 4a49fdf466bb..72a3a54d43e8 100644 --- a/src/main/orcad/orcad-host-unavailable.ts +++ b/src/main/orcad/orcad-host-unavailable.ts @@ -36,6 +36,7 @@ export type OrcadHostUnavailableReason = | 'artifacts_unavailable' | 'libc_unidentified' | 'runtime_self_test' + | 'runtime_noexec' | 'security_software' | 'native_preflight' | typeof ORCAD_TUNNEL_UNAVAILABLE_REASON @@ -46,6 +47,7 @@ const UNAVAILABLE_BY_ERROR_NAME: Record = { OrcadRemoteLaunchUnsupportedError: 'unsupported_host', UnidentifiedHostLibcError: 'libc_unidentified', RemoteNodeRuntimeSelfTestError: 'runtime_self_test', + RemoteNodeRuntimeNoexecError: 'runtime_noexec', RemoteNodeRuntimeSecurityModifiedError: 'security_software', OrcadStdioBridgeUnavailableError: ORCAD_TUNNEL_UNAVAILABLE_REASON, OrcadWindowsLaunchRefusedError: 'unsupported_host', @@ -61,6 +63,11 @@ const UNAVAILABLE_BY_DEFERRAL_CODE: Record = [ORCAD_WINDOWS_COMMAND_LINE_UNSAFE_CODE]: 'unsupported_host' } +/** Host settings the user can change, so a later connect checks again instead of remembering it. */ +export function isOrcadHostUnavailableFixableOnHost(reason: string): boolean { + return reason === 'runtime_noexec' +} + /** The deferral code for a candidate launch failure, keeping the permanent Windows refusals. */ export function orcadCandidateLaunchFailureCode(error: unknown): string { const code = error instanceof Error && 'code' in error ? error.code : undefined diff --git a/src/main/persistence-closed-pane-binding-fence.test.ts b/src/main/persistence-closed-pane-binding-fence.test.ts new file mode 100644 index 000000000000..fdb0e51412e1 --- /dev/null +++ b/src/main/persistence-closed-pane-binding-fence.test.ts @@ -0,0 +1,225 @@ +import { closeTestStores, createStore, testState } from './persistence-test-harness' +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' +import { collectLayoutLeafIdsInOrder } from '../shared/workspace-layout/terminal-pane-tree' +import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { _resetClosedPaneFencesForTests } from './persistence/terminal-topology/closed-pane-binding-fence' +import { closeLeafOrTab } from './persistence/terminal-topology/terminal-topology-commit' +import { captureTerminalCloseRequest } from './runtime/terminal-surface-close' +import type { TerminalSurfaceCloseTarget } from '../shared/terminal-surface-close-target' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) +vi.mock('../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const WORKTREE = 'repo1::/worktree' +const TAB = 'host-tab' + +type TestStore = ReturnType + +/** A tab main wrote and whose terminal started, then a split main wrote whose terminal is starting. */ +async function tabWithStartingSplit(store: TestStore): Promise { + expect( + await store.admitTerminalPane({ + type: 'createTerminalTab', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1 + }) + ).toBe('admitted') + expect( + await store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + ptyId: 'source-pty', + mayCreate: false + }) + ).toBe(true) + expect( + await store.admitTerminalPane({ + type: 'splitPane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: TEST_LEAF_2 + }) + ).toBe('admitted') +} + +/** The window's spawn of the split's terminal committing late: it names no expected state. */ +function lateWindowBinding(store: TestStore): Promise { + return store.persistPtyBinding({ + worktreeId: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2, + ptyId: 'late-pty', + origin: 'spawn' + }) +} + +/** Main's own close of a pane or tab (the CLI's, a phone's), through the one close transaction. */ +async function closeThroughMain( + store: TestStore, + target: TerminalSurfaceCloseTarget +): Promise { + const hostIds: readonly ExecutionHostId[] = [LOCAL_EXECUTION_HOST_ID] + const refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId: WORKTREE, + target, + options: { allowMissing: true }, + ...captureTerminalCloseRequest(WORKTREE, target, hostIds, store, false), + ownerMatches: () => true, + hostIds: () => hostIds, + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) + expect(refusal).toBeUndefined() +} + +function leafIds(store: TestStore): string[] { + const root = store.getWorkspaceSession().terminalLayoutsByTabId[TAB]?.root + return root ? collectLayoutLeafIdsInOrder(root) : [] +} + +describe('a pane main closed or took back is not brought back by its late terminal binding', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-closed-pane-fence-')) + }) + + afterEach(async () => { + vi.useRealTimers() + _resetClosedPaneFencesForTests() + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('refuses the binding of a split main took back when its terminal did not start in time', async () => { + const store = createStore() + await tabWithStartingSplit(store) + + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2 + }) + + expect(await lateWindowBinding(store)).toBe(false) + expect(leafIds(store)).toEqual([TEST_LEAF_1]) + }) + + it('refuses the binding of a pane the user closed while its terminal was starting', async () => { + const store = createStore() + await tabWithStartingSplit(store) + + const reply = await store.applyLayoutCommand({ + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2, + userPaneClose: true + }) + expect(reply.ok).toBe(true) + + expect(await lateWindowBinding(store)).toBe(false) + expect(leafIds(store)).toEqual([TEST_LEAF_1]) + }) + + it('refuses the binding of a pane main closed while its terminal was starting', async () => { + const store = createStore() + await tabWithStartingSplit(store) + + await closeThroughMain(store, { kind: 'pane', tabId: TAB, leafId: TEST_LEAF_2 }) + + expect(await lateWindowBinding(store)).toBe(false) + expect(leafIds(store)).toEqual([TEST_LEAF_1]) + }) + + it('refuses the binding of a pane whose tab the window closed while it was starting', async () => { + const store = createStore() + await tabWithStartingSplit(store) + + const reply = await store.applyLayoutCommand({ + type: 'closeTabs', + workspace: WORKTREE, + tabIds: [TAB], + force: true + }) + expect(reply.ok).toBe(true) + + expect(await lateWindowBinding(store)).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB]).toBeUndefined() + }) + + it('binds a pane the window creates again under the same id', async () => { + const store = createStore() + await tabWithStartingSplit(store) + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2 + }) + + const reply = await store.applyLayoutCommand({ + type: 'splitPane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: TEST_LEAF_2 + }) + expect(reply.ok).toBe(true) + expect(await lateWindowBinding(store)).toBe(true) + }) + + it('binds a pane a real create wrote again under the same id', async () => { + const store = createStore() + await tabWithStartingSplit(store) + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2 + }) + + expect( + await store.admitTerminalPane({ + type: 'splitPane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_1, + direction: 'vertical', + newLeafId: TEST_LEAF_2 + }) + ).toBe('admitted') + expect(await lateWindowBinding(store)).toBe(true) + expect(leafIds(store)).toEqual([TEST_LEAF_1, TEST_LEAF_2]) + }) + + it('lets a binding create the pane again once the binding window has passed', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const store = createStore() + await tabWithStartingSplit(store) + await store.withdrawTerminalPane({ + type: 'closePane', + workspace: WORKTREE, + tabId: TAB, + leafId: TEST_LEAF_2 + }) + + vi.setSystemTime(Date.now() + 5 * 60_000 + 1_000) + expect(await lateWindowBinding(store)).toBe(true) + expect(leafIds(store)).toEqual([TEST_LEAF_1, TEST_LEAF_2]) + }) +}) diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts index 69a00dc96fa7..ab1e5c6af017 100644 --- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts +++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts @@ -164,6 +164,52 @@ describe('deleting one host copy of a repo id shared by two hosts', () => { ) }) + it.each(['local', 'ssh:ssh-1'] as const)( + 'deleteProjectHostSetup with hostId %s removes that host twin only', + async (hostId) => { + const store = await createStoreWithDuplicateRepoId() + + const result = store.deleteProjectHostSetup({ setupId: 'dup', hostId }) + + expect(result?.setup.hostId).toBe(hostId) + expect(store.getRepos().map((repo) => repo.path)).toEqual( + hostId === 'local' ? ['/remote/dup'] : ['/laptop/dup'] + ) + } + ) + + it.each(['local', 'ssh:ssh-1'] as const)( + 'updateProjectHostSetup with hostId %s renames that host twin only', + async (hostId) => { + const store = await createStoreWithDuplicateRepoId() + + const result = store.updateProjectHostSetup({ + setupId: 'dup', + hostId, + updates: { displayName: 'Renamed' } + }) + + expect(result?.setup.hostId).toBe(hostId) + expect(store.getRepos().map((repo) => repo.displayName)).toEqual( + hostId === 'local' ? ['Renamed', 'Dup Remote'] : ['Dup Local', 'Renamed'] + ) + } + ) + + it('a hostId naming no twin edits nothing', async () => { + const store = await createStoreWithDuplicateRepoId() + + expect( + store.updateProjectHostSetup({ + setupId: 'dup', + hostId: 'ssh:other', + updates: { displayName: 'Renamed' } + }) + ).toBeNull() + expect(store.deleteProjectHostSetup({ setupId: 'dup', hostId: 'ssh:other' })).toBeNull() + expect(store.getRepos().map((repo) => repo.displayName)).toEqual(['Dup Local', 'Dup Remote']) + }) + it('setResolvedRepoGitUsername writes only the probed host row', async () => { // Enrichment resolves per repo *location*, so the write must land on the row it probed; an // id-only lookup would stamp the sibling host's username and host-scoped hydration cache. diff --git a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts index 3784f35c75a2..81aa3c67b346 100644 --- a/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts +++ b/src/main/persistence-host-partitioned-ssh-pty-bindings.test.ts @@ -14,6 +14,7 @@ import type { WorkspaceSessionState } from '../shared/workspace-session-state-ty import { getDefaultWorkspaceSession } from '../shared/constants' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { PtyExitProof } from './pty-exit-proof' const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ trackMock: vi.fn(), @@ -154,7 +155,7 @@ describe('Store SSH remote PTY bindings across host partitions', () => { store.setWorkspaceSession(makeBoundHostSession(ptyId), 'local') store.setWorkspaceSession(makeBoundHostSession(ptyId), 'ssh:ssh-1') - store.markSshRemotePtyLease('ssh-1', ptyId, 'terminated') + store.markSshRemotePtyLease('ssh-1', ptyId, PtyExitProof.from('exit-observed')) for (const hostId of ['local', 'ssh:ssh-1']) { const session = store.getWorkspaceSession(hostId) diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index ee663d302a2e..f5c62bc3f43c 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -391,13 +391,16 @@ describe('Store', () => { const store = await createStore() // A re-probe that now prefers the `upstream` remote rewrites the derived project id. - store.updateRepo('r1', { - gitRemoteIdentity: { - canonicalKey: 'git.example.com/acme/app-upstream', - remoteName: 'upstream', - remoteUrl: 'git@git.example.com:acme/app-upstream.git' + store.updateRepo( + { id: 'r1' }, + { + gitRemoteIdentity: { + canonicalKey: 'git.example.com/acme/app-upstream', + remoteName: 'upstream', + remoteUrl: 'git@git.example.com:acme/app-upstream.git' + } } - }) + ) const upstreamProjectId = 'git:git.example.com/acme/app-upstream' expect(store.getProjects().map((project) => project.id)).toEqual([upstreamProjectId]) diff --git a/src/main/persistence-jira-project.test.ts b/src/main/persistence-jira-project.test.ts index 4346e73c06ad..659cd4e0eb72 100644 --- a/src/main/persistence-jira-project.test.ts +++ b/src/main/persistence-jira-project.test.ts @@ -18,13 +18,13 @@ describe('persisted repository Jira defaults', () => { it('persists and clears the project default across restart without requiring Git', () => { const store = createStore() store.addRepo(makeRepo({ kind: 'folder' })) - expect(store.updateRepo('r1', { defaultJiraProject: project })?.defaultJiraProject).toEqual( - project - ) + expect( + store.updateRepo({ id: 'r1' }, { defaultJiraProject: project })?.defaultJiraProject + ).toEqual(project) store.flush() const reloaded = createStore() expect(reloaded.getRepo('r1')?.defaultJiraProject).toEqual(project) - reloaded.updateRepo('r1', { defaultJiraProject: null }) + reloaded.updateRepo({ id: 'r1' }, { defaultJiraProject: null }) reloaded.flush() expect(createStore().getRepo('r1')?.defaultJiraProject).toBeNull() }) diff --git a/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts b/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts new file mode 100644 index 000000000000..fa4707698c9c --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts-durability.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' +import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' +import { + commitStagedOrcadMigrationCatalogDurably, + getOrcadMigrationCatalogState +} from './runtime/orcad-migration-catalog-import' +import { + DRAFT_A, + FILE_A, + HINTED_PANE, + commitDurably, + declaredManifest, + draftOf, + encodeDrafts, + encodeHints, + openFile, + receivedDraftsPath, + stageBlob, + type Store +} from './persistence-orcad-migration-editor-drafts-fixture' +import { + DORMANT_WORKTREE_ID, + dormantState, + manifest +} from './persistence-orcad-migration-catalog-fixture' +import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +const noLiveShells = (): boolean => false + +function receivedHintsPath(input: OrcadMigrationManifest): string { + return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { + blobKind: 'terminal-restore-hints', + key: 'migration' + }) +} + +function hint(store: Store): string | null { + return store.peekOrcadMigrationTerminalRestoreHint(HINTED_PANE, DORMANT_WORKTREE_ID) +} + +/** Drafts plus restore hints staged and durable; returns the disk state before any commit. */ +async function stageDraftsAndHints(store: Store) { + const drafts = encodeDrafts([DRAFT_A]) + const hints = encodeHints('/home/me/src') + const input = declaredManifest(drafts.descriptor) + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, drafts) + stageBlob(store, input, hints) + await store.flushPendingOrThrowAsync() + return { input, expectedBlobs: [drafts.descriptor, hints.descriptor], stagedDisk: readDataFile() } +} + +async function commitWithFailedFlush(store: Store, input: OrcadMigrationManifest) { + vi.spyOn(store, 'flushPendingOrThrowAsync').mockRejectedValueOnce(new Error('disk full')) + await expect(commitDurably(store, input)).rejects.toThrow('disk full') +} + +describe('orcad migration received blobs wait for a durable commit', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-drafts-durable-')) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps every received blob through a failed flush and a state query, for the retry after restart', async () => { + const store = createStore() + const { input, expectedBlobs, stagedDisk } = await stageDraftsAndHints(store) + await commitWithFailedFlush(store, input) + + // The in-memory receipt answers "committed", but nothing it received may be consumed yet. + expect(getOrcadMigrationCatalogState({ store, manifest: input }).state).toBe('committed') + expect(existsSync(receivedDraftsPath(input))).toBe(true) + expect(existsSync(receivedHintsPath(input))).toBe(true) + expect(hint(store)).toBeNull() + + writeDataFile(stagedDisk) + const restarted = createStore() + expect(restarted.getOrcadMigrationCatalogState(input).state).toBe('staged') + const result = await commitStagedOrcadMigrationCatalogDurably({ + store: restarted, + manifest: input, + expectedBlobs, + onDurableCommit: () => {} + }) + expect(result.state).toBe('committed') + expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) + expect(hint(restarted)).toBe('/home/me/src') + expect(existsSync(receivedDraftsPath(input))).toBe(false) + expect(existsSync(receivedHintsPath(input))).toBe(false) + }) + + it('never consumes an in-memory-only commit in the post-attach pass', async () => { + const store = createStore() + const { input } = await stageDraftsAndHints(store) + await commitWithFailedFlush(store, input) + + store.installPendingOrcadMigrationReceivedBlobs({ worktreeHasLiveShells: noLiveShells }) + store.installDeferredOrcadMigrationShellHistory(DORMANT_WORKTREE_ID, { + worktreeHasLiveShells: noLiveShells + }) + expect(existsSync(receivedDraftsPath(input))).toBe(true) + expect(existsSync(receivedHintsPath(input))).toBe(true) + }) + + it('releases the blobs when a same-process retry flushes the commit', async () => { + const store = createStore() + const { input, expectedBlobs } = await stageDraftsAndHints(store) + await commitWithFailedFlush(store, input) + + const retried = await commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + expectedBlobs, + onDurableCommit: () => {} + }) + expect(retried.state).toBe('committed') + expect(existsSync(receivedDraftsPath(input))).toBe(false) + expect(existsSync(receivedHintsPath(input))).toBe(false) + expect(hint(store)).toBe('/home/me/src') + expect(draftOf(createStore(), FILE_A)).toBe(DRAFT_A.content) + }) + + it('treats any successful write of the commit as proof, and a load from disk as proof', async () => { + const store = createStore() + const { input } = await stageDraftsAndHints(store) + store.commitStagedOrcadMigrationCatalog(input) + expect(getOrcadMigrationCatalogState({ store, manifest: input }).state).toBe('committed') + expect(existsSync(receivedDraftsPath(input))).toBe(true) + await store.flushPendingOrThrowAsync() + + const restarted = createStore() + expect(existsSync(receivedDraftsPath(input))).toBe(false) + expect(existsSync(receivedHintsPath(input))).toBe(false) + expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) + expect(hint(restarted)).toBe('/home/me/src') + }) + + it('commits an older desktop inline draft unchanged', async () => { + const dormant = dormantState() + dormant.workspaceSession = { + ...dormant.workspaceSession!, + openFilesByWorktree: { [DORMANT_WORKTREE_ID]: [openFile(FILE_A, DRAFT_A.content)] } + } + const base = manifest() + const input = manifest({ payload: { ...base.payload, dormantState: dormant } }) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + + expect((await commitDurably(store, input)).state).toBe('committed') + expect(draftOf(createStore(), FILE_A)).toBe(DRAFT_A.content) + }) +}) diff --git a/src/main/persistence-orcad-migration-editor-drafts-fixture.ts b/src/main/persistence-orcad-migration-editor-drafts-fixture.ts new file mode 100644 index 000000000000..4e0b8e812e94 --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts-fixture.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { + ORCAD_MIGRATION_BLOB_CHUNK_BYTES, + type OrcadMigrationBlobDescriptor, + type OrcadMigrationBlobKind +} from '../shared/orcad-migration/orcad-migration-blob-transfer' +import type { OrcadMigrationEditorDraft } from '../shared/orcad-migration/orcad-migration-editor-drafts' +import type { OrcadMigrationManifest } from '../shared/orcad-migration/orcad-migration-manifest' +import type { PersistedOpenFile } from '../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { getOrcadMigrationReceivedBlobPath } from './persistence/migrating-orcad-catalog/orcad-migration-blob-staging' +import { commitStagedOrcadMigrationCatalogDurably } from './runtime/orcad-migration-catalog-import' +import { + DORMANT_LEAF_ID, + DORMANT_WORKTREE_ID, + dormantState, + manifest +} from './persistence-orcad-migration-catalog-fixture' +import { testState, type createStore } from './persistence-test-harness' + +export type Store = ReturnType + +export const FILE_A = '/srv/repo-1-worktree/src/a.ts' +export const FILE_B = '/srv/repo-1-worktree/src/b.ts' + +export function openFile(filePath: string, dirtyDraftContent?: string): PersistedOpenFile { + return { + filePath, + relativePath: filePath.slice('/srv/repo-1-worktree/'.length), + worktreeId: DORMANT_WORKTREE_ID, + language: 'typescript', + ...(dirtyDraftContent === undefined ? {} : { dirtyDraftContent }) + } +} + +export type EncodedBlob = { bytes: Buffer; descriptor: OrcadMigrationBlobDescriptor } + +function encodeBlob(blobKind: OrcadMigrationBlobKind, key: string, value: unknown): EncodedBlob { + const bytes = Buffer.from(JSON.stringify(value)) + return { + bytes, + descriptor: { + blobKind, + key, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + } + } +} + +export function encodeDrafts(drafts: OrcadMigrationEditorDraft[]): EncodedBlob { + return encodeBlob('editor-drafts', 'drafts', { version: 1, drafts }) +} + +/** A restore hint for the fixture's dormant pane. */ +export const HINTED_PANE = `tab-dormant:${DORMANT_LEAF_ID}` +export function encodeHints(cwd: string): EncodedBlob { + return encodeBlob('terminal-restore-hints', 'migration', { + version: 1, + hints: [{ paneKey: HINTED_PANE, cwd }] + }) +} + +/** A capable desktop's manifest: FILE_A's draft shed into the blob, FILE_B clean. */ +export function declaredManifest( + declared: OrcadMigrationBlobDescriptor | null, + entries = [{ worktreeKey: DORMANT_WORKTREE_ID, filePath: FILE_A }] +): OrcadMigrationManifest { + const dormant = dormantState() + dormant.workspaceSession = { + ...dormant.workspaceSession!, + openFilesByWorktree: { [DORMANT_WORKTREE_ID]: [openFile(FILE_A), openFile(FILE_B)] } + } + if (declared) { + dormant.editorDraftsBlob = { + sha256: declared.sha256, + byteLength: declared.byteLength, + entries + } + } + const base = manifest() + return manifest({ payload: { ...base.payload, dormantState: dormant } }) +} + +export function stageBlob(store: Store, input: OrcadMigrationManifest, blob: EncodedBlob) { + for (let offset = 0; offset < blob.bytes.length; offset += ORCAD_MIGRATION_BLOB_CHUNK_BYTES) { + store.stageOrcadMigrationBlobChunk({ + migrationId: input.migrationId, + manifestSha256: input.manifestSha256, + blob: blob.descriptor, + offset, + bytesBase64: blob.bytes + .subarray(offset, offset + ORCAD_MIGRATION_BLOB_CHUNK_BYTES) + .toString('base64') + }) + } +} + +export function receivedDraftsPath(input: OrcadMigrationManifest): string { + return getOrcadMigrationReceivedBlobPath(join(testState.dir, 'orcad-migration-blobs'), input, { + blobKind: 'editor-drafts', + key: 'drafts' + }) +} + +export function draftOf(store: Store, filePath: string): string | undefined { + return store + .getWorkspaceSession() + .openFilesByWorktree?.[DORMANT_WORKTREE_ID]?.find((file) => file.filePath === filePath) + ?.dirtyDraftContent +} + +export function setDestinationFiles(store: Store, files: PersistedOpenFile[]): void { + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [DORMANT_WORKTREE_ID]: files } + }) +} + +export function commitDurably(store: Store, input: OrcadMigrationManifest) { + return commitStagedOrcadMigrationCatalogDurably({ + store, + manifest: input, + onDurableCommit: () => {} + }) +} + +export const DRAFT_A = { + worktreeKey: DORMANT_WORKTREE_ID, + filePath: FILE_A, + content: 'unsaved A\n' +} diff --git a/src/main/persistence-orcad-migration-editor-drafts.test.ts b/src/main/persistence-orcad-migration-editor-drafts.test.ts new file mode 100644 index 000000000000..9e6b5b3facd5 --- /dev/null +++ b/src/main/persistence-orcad-migration-editor-drafts.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + DRAFT_A, + FILE_A, + FILE_B, + commitDurably, + declaredManifest, + draftOf, + encodeDrafts, + openFile, + receivedDraftsPath, + setDestinationFiles, + stageBlob +} from './persistence-orcad-migration-editor-drafts-fixture' +import { DORMANT_WORKTREE_ID } from './persistence-orcad-migration-catalog-fixture' +import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +describe('orcad migration editor drafts blob', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-migration-drafts-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('applies an 8 MiB draft byte-identical inside the commit and releases the blob once durable', async () => { + const content = `${'x'.repeat(8 * 1024 * 1024 - 7)}รฉ\n\u0000end` + const blob = encodeDrafts([{ ...DRAFT_A, content }]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + + expect((await commitDurably(store, input)).state).toBe('committed') + expect(draftOf(store, FILE_A)).toBe(content) + expect(draftOf(store, FILE_B)).toBeUndefined() + expect(existsSync(receivedDraftsPath(input))).toBe(false) + expect(draftOf(createStore(), FILE_A)).toBe(content) + }) + + it('refuses a declared blob that never arrived, whether or not the sender names its blobs', () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_editor_drafts_missing' + ) + expect(() => store.commitStagedOrcadMigrationCatalog(input, { expectedBlobs: [] })).toThrow( + 'orcad_migration_editor_drafts_missing' + ) + expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') + expect(store.getRepos()).toEqual([]) + }) + + it('refuses a drafts blob the manifest does not declare', () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(null) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_editor_drafts_undeclared' + ) + expect(store.getRepos()).toEqual([]) + }) + + it('refuses a draft for an entry the manifest did not shed', () => { + const blob = encodeDrafts([ + DRAFT_A, + { worktreeKey: DORMANT_WORKTREE_ID, filePath: FILE_B, content: 'extra' } + ]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_editor_drafts_set_mismatch' + ) + expect(store.getOrcadMigrationCatalogState(input)).toMatchObject({ + state: 'staged', + blobUploads: [{ ...blob.descriptor, receivedBytes: blob.descriptor.byteLength }] + }) + }) + + it('refuses bytes whose digest differs from the signed declaration', () => { + const declared = encodeDrafts([DRAFT_A]) + const sent = encodeDrafts([{ ...DRAFT_A, content: 'tampered' }]) + const input = declaredManifest(declared.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, sent) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_editor_drafts_digest_mismatch' + ) + expect(store.getRepos()).toEqual([]) + }) + + it('accepts a destination entry already holding the identical draft', () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + setDestinationFiles(store, [openFile(FILE_A, DRAFT_A.content), openFile(FILE_B)]) + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + + expect(store.commitStagedOrcadMigrationCatalog(input).state).toBe('committed') + expect(draftOf(store, FILE_A)).toBe(DRAFT_A.content) + }) + + it('stages past a competing destination draft but refuses it at commit, keeping staging', () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + setDestinationFiles(store, [openFile(FILE_A, 'destination edit'), openFile(FILE_B)]) + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:openFilesByWorktree:${DORMANT_WORKTREE_ID}` + ) + expect(draftOf(store, FILE_A)).toBe('destination edit') + expect(store.getOrcadMigrationCatalogState(input)).toMatchObject({ + state: 'staged', + blobUploads: [{ ...blob.descriptor, receivedBytes: blob.descriptor.byteLength }] + }) + expect(existsSync(receivedDraftsPath(input))).toBe(false) + }) + + it('refuses a destination draft edited between stage and commit', () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + setDestinationFiles(store, [openFile(FILE_A, DRAFT_A.content), openFile(FILE_B)]) + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + setDestinationFiles(store, [openFile(FILE_A, 'edited after staging'), openFile(FILE_B)]) + + expect(() => store.commitStagedOrcadMigrationCatalog(input)).toThrow( + `orcad_migration_dormant_id_conflict:workspace_session:openFilesByWorktree:${DORMANT_WORKTREE_ID}` + ) + expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') + }) + + it('re-applies the received drafts on a retry after the first commit was lost', async () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + await store.flushPendingOrThrowAsync() + const stagedDiskState = readDataFile() + store.commitStagedOrcadMigrationCatalog(input) + writeDataFile(stagedDiskState) + + // Staging is gone: the first commit moved the blob, so only the received bytes remain. + const restarted = createStore() + expect(restarted.getOrcadMigrationCatalogState(input)).toMatchObject({ state: 'staged' }) + expect(restarted.getOrcadMigrationCatalogState(input)).not.toHaveProperty('blobUploads') + expect(draftOf(restarted, FILE_A)).toBeUndefined() + expect((await commitDurably(restarted, input)).state).toBe('committed') + expect(draftOf(restarted, FILE_A)).toBe(DRAFT_A.content) + expect(existsSync(receivedDraftsPath(input))).toBe(false) + }) + + it('refuses a received-only retry whose bytes were damaged', async () => { + const blob = encodeDrafts([DRAFT_A]) + const input = declaredManifest(blob.descriptor) + const store = createStore() + store.stageOrcadMigrationCatalog(input) + stageBlob(store, input, blob) + await store.flushPendingOrThrowAsync() + const stagedDiskState = readDataFile() + store.commitStagedOrcadMigrationCatalog(input) + writeDataFile(stagedDiskState) + writeFileSync(receivedDraftsPath(input), Buffer.from('damaged')) + + const restarted = createStore() + expect(() => restarted.commitStagedOrcadMigrationCatalog(input)).toThrow( + 'orcad_migration_editor_drafts_digest_mismatch' + ) + expect(restarted.getOrcadMigrationCatalogState(input).state).toBe('staged') + }) +}) diff --git a/src/main/persistence-orcad-repository-cache-reimport.test.ts b/src/main/persistence-orcad-repository-cache-reimport.test.ts index c37d988e4329..5525b431ffbd 100644 --- a/src/main/persistence-orcad-repository-cache-reimport.test.ts +++ b/src/main/persistence-orcad-repository-cache-reimport.test.ts @@ -34,7 +34,7 @@ describe('reimporting a repository after the destination resolves its Git identi async (identity) => { const store = createStore() commit(store, manifest()) - store.updateRepo(REPOSITORY.id, { gitRemoteIdentity: identity }) + store.updateRepo(store.getRepos()[0]!, { gitRemoteIdentity: identity }) await store.flushPendingOrThrowAsync() await closeTestStores() const restored = createStore() @@ -50,7 +50,7 @@ describe('reimporting a repository after the destination resolves its Git identi it('preserves the current cache when the incoming cache is stale', () => { const store = createStore() commit(store, manifest()) - store.updateRepo(REPOSITORY.id, { gitRemoteIdentity: remoteIdentity }) + store.updateRepo(store.getRepos()[0]!, { gitRemoteIdentity: remoteIdentity }) const next = manifest({ migrationId: 'migration-stale-cache', payload: { ...manifest().payload, repositories: [{ ...REPOSITORY, gitRemoteIdentity: null }] } @@ -64,7 +64,7 @@ describe('reimporting a repository after the destination resolves its Git identi (changes) => { const store = createStore() commit(store, manifest()) - store.updateRepo(REPOSITORY.id, { gitRemoteIdentity: remoteIdentity }) + store.updateRepo(store.getRepos()[0]!, { gitRemoteIdentity: remoteIdentity }) const next = manifest({ migrationId: 'migration-conflict', payload: { ...manifest().payload, repositories: [{ ...REPOSITORY, ...changes }] } @@ -91,7 +91,7 @@ describe('reimporting a repository after the destination resolves its Git identi manifest({ migrationId, payload: { ...manifest().payload, repositories: [source] } }) const store = createStore() commit(store, withSource('m1')) - store.updateRepo(REPOSITORY.id, { gitRemoteIdentity: remoteIdentity, repoIcon: newIcon }) + store.updateRepo(store.getRepos()[0]!, { gitRemoteIdentity: remoteIdentity, repoIcon: newIcon }) expect(commit(store, withSource('m2'))).toMatchObject({ state: 'committed' }) expect(store.getRepos()[0]).toMatchObject({ @@ -103,7 +103,7 @@ describe('reimporting a repository after the destination resolves its Git identi it('still refuses a changed icon the user chose', () => { const store = createStore() commit(store, manifest()) - store.updateRepo(REPOSITORY.id, { + store.updateRepo(store.getRepos()[0]!, { repoIcon: githubAvatarIcon({ owner: 'example', repo: 'project' }) }) const next = manifest({ diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 78b6ebee23d0..e9e58d053435 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -255,10 +255,13 @@ describe('Store', () => { const group = store.createProjectGroup({ name: 'Platform', createdFrom: 'manual' }) store.addRepo(makeRepo({ id: 'r1', projectGroupId: group.id, projectGroupOrder: 1 })) - const updated = store.updateRepo('r1', { - projectGroupId: '', - projectGroupOrder: Number.POSITIVE_INFINITY - } as never) + const updated = store.updateRepo( + { id: 'r1' }, + { + projectGroupId: '', + projectGroupOrder: Number.POSITIVE_INFINITY + } + ) expect(updated?.projectGroupId).toBeNull() expect(updated?.projectGroupOrder).toBe(1) @@ -268,7 +271,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1' })) - const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' }) + const updated = store.updateRepo({ id: 'r1' }, { executionHostId: 'runtime:env-1' }) expect(updated?.executionHostId).toBe('runtime:env-1') expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1') diff --git a/src/main/persistence-ssh-lease-tombstone-retention.test.ts b/src/main/persistence-ssh-lease-tombstone-retention.test.ts index 8ea734158db1..ed0aa9798074 100644 --- a/src/main/persistence-ssh-lease-tombstone-retention.test.ts +++ b/src/main/persistence-ssh-lease-tombstone-retention.test.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { PtyExitProof } from './pty-exit-proof' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -76,7 +77,11 @@ describe('operator-closed SSH lease tombstones', () => { it('keeps the tombstone while a binding the scrub could not reach still names the pty', async () => { const store = await storeWithDetachedPaneBinding() - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty', + PtyExitProof.from('exit-observed') + ) // `isRestorablePtyBinding` still consults this row to refuse replaying that binding. expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ @@ -93,7 +98,11 @@ describe('operator-closed SSH lease tombstones', () => { { targetId: 'ssh-1', ptyId: 'remote-pty', state: 'attached', pendingKill } ]) - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty', + PtyExitProof.from('exit-observed') + ) expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ ptyId: 'remote-pty', state: 'terminated', pendingKill }) @@ -124,7 +133,11 @@ describe('operator-closed SSH lease tombstones', () => { { targetId: 'ssh-1', ptyId: 'remote-pty-3', state: 'attached' } ]) - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty-3', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty-3', + PtyExitProof.from('exit-observed') + ) expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([ expect.objectContaining({ @@ -147,7 +160,11 @@ describe('operator-closed SSH lease tombstones', () => { ]) expect(store.getSshRemotePtyLeases()).toHaveLength(4) - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty-1', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty-1', + PtyExitProof.from('exit-observed') + ) // Other targets are untouched: the pass is scoped to the one whose bindings were just scrubbed. expect(store.getSshRemotePtyLeases()).toEqual([ diff --git a/src/main/persistence-ssh-pending-pty-kill.test.ts b/src/main/persistence-ssh-pending-pty-kill.test.ts index 7b013b2d9210..3f6a7aafb620 100644 --- a/src/main/persistence-ssh-pending-pty-kill.test.ts +++ b/src/main/persistence-ssh-pending-pty-kill.test.ts @@ -23,8 +23,8 @@ vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ( const NOW = 1_800_000_000_000 -// Relay-era builds record undelivered stops on the lease; this build only carries them so a -// downgrade can still replay them. +// Relay-era builds record undelivered stops on the lease; this build writes them only for the +// legacy relay bridge, and otherwise carries them so a downgrade can still replay them. describe('persisted SSH pending PTY kills', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) @@ -71,4 +71,22 @@ describe('persisted SSH pending PTY kills', () => { { ptyId: 'pty-2', pendingKill: undefined } ]) }) + + it('keeps a stop the legacy relay bridge records across a restart, until it clears it', async () => { + const store = createStoreWithSshRemotePtyLeases([ + { targetId: 'ssh-1', ptyId: 'pty2:epoch-a:1', state: 'detached', tabId: 'tab-1' } + ]) + store.setSshRemotePtyLeasePendingKill('ssh-1', 'pty2:epoch-a:1', { + requestedAt: NOW, + attempts: 0 + }) + + const reloaded = createStore() + expect(reloaded.getSshRemotePtyLeases('ssh-1')[0]).toMatchObject({ + state: 'detached', + pendingKill: { requestedAt: NOW, attempts: 0 } + }) + reloaded.setSshRemotePtyLeasePendingKill('ssh-1', 'pty2:epoch-a:1', null) + expect(createStore().getSshRemotePtyLeases('ssh-1')[0].pendingKill).toBeUndefined() + }) }) diff --git a/src/main/persistence-ssh-remote-pty-leases.test.ts b/src/main/persistence-ssh-remote-pty-leases.test.ts index d74f55ca3e4e..13da1dc1d9a7 100644 --- a/src/main/persistence-ssh-remote-pty-leases.test.ts +++ b/src/main/persistence-ssh-remote-pty-leases.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import { getDefaultPersistedState } from '../shared/constants' import { sshRemotePtyLeaseAllowsReattach } from '../shared/ssh-types' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' +import { PtyExitProof } from './pty-exit-proof' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -549,7 +550,11 @@ describe('Store', () => { const store = await createStore() expect(() => - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-2@@remote-pty', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-2@@remote-pty', + PtyExitProof.from('exit-observed') + ) ).toThrow('belongs to SSH connection "ssh-2"') }) @@ -562,7 +567,11 @@ describe('Store', () => { } ]) - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty', + PtyExitProof.from('exit-observed') + ) // An unresolved id would have left the lease `attached`; this unbound row is retired instead. expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) @@ -571,7 +580,11 @@ describe('Store', () => { it('clears workspace bindings when marking an SSH remote PTY lease terminated', async () => { const store = await storeWithBoundSshPane() - store.markSshRemotePtyLease('ssh-1', 'ssh:ssh-1@@remote-pty', 'terminated') + store.markSshRemotePtyLease( + 'ssh-1', + 'ssh:ssh-1@@remote-pty', + PtyExitProof.from('exit-observed') + ) const session = store.getWorkspaceSession() expect(store.getSshRemotePtyLeases('ssh-1')).toEqual([]) diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index fe8096bcb91f..89ea94e95981 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -82,7 +82,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { displayName: 'renamed' }) + const updated = store.updateRepo({ id: 'r1' }, { displayName: 'renamed' }) expect(updated).not.toBeNull() expect(updated!.displayName).toBe('renamed') expect(store.getRepo('r1')!.displayName).toBe('renamed') @@ -95,14 +95,14 @@ describe('Store', () => { const initialGeneration = getLocalWorktreeScanGeneration('scan-round-trip') expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(true) - const folder = store.updateRepo('scan-round-trip', { kind: 'folder' }) + const folder = store.updateRepo({ id: 'scan-round-trip' }, { kind: 'folder' }) expect(folder?.kind).toBe('folder') expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(false) const folderGeneration = getLocalWorktreeScanGeneration('scan-round-trip') expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(true) - const git = store.updateRepo('scan-round-trip', { kind: 'git' }) + const git = store.updateRepo({ id: 'scan-round-trip' }, { kind: 'git' }) expect(git?.kind).toBe('git') expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(false) }) @@ -119,7 +119,10 @@ describe('Store', () => { }) ) - const updated = store.updateRepo('shared', { displayName: 'Remote renamed' }, 'ssh:server') + const updated = store.updateRepo( + { id: 'shared', executionHostId: 'ssh:server' }, + { displayName: 'Remote renamed' } + ) expect(updated?.path).toBe('/remote/repo') expect(store.getRepos().filter((repo) => repo.id === 'shared')).toMatchObject([ @@ -128,15 +131,30 @@ describe('Store', () => { ]) }) + it('updateRepo writes the row it was handed, not the first row with that id', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/remote/repo', connectionId: 'server' })) + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + const local = store.getRepos().find((repo) => repo.path === '/local/repo')! + + const updated = store.updateRepo(local, { displayName: 'Local renamed' }) + + expect(updated?.path).toBe('/local/repo') + expect(store.getRepos().map((repo) => repo.displayName)).toEqual(['test', 'Local renamed']) + }) + it('updateRepo keeps project host setup compatibility records in sync', async () => { const store = await createStore() store.addRepo(makeRepo({ worktreeBasePath: '../worktrees' })) - store.updateRepo('r1', { - displayName: 'renamed', - worktreeBasePath: '../new-worktrees', - upstream: { owner: 'stablyai', repo: 'orca' } - }) + store.updateRepo( + { id: 'r1' }, + { + displayName: 'renamed', + worktreeBasePath: '../new-worktrees', + upstream: { owner: 'stablyai', repo: 'orca' } + } + ) expect(store.getProjects()).toEqual([ expect.objectContaining({ @@ -176,7 +194,7 @@ describe('Store', () => { }) const store = await createStore() - store.updateRepo('r1', { displayName: 'renamed' }) + store.updateRepo({ id: 'r1' }, { displayName: 'renamed' }) store.reorderRepos(['r1']) expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project']) @@ -459,7 +477,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - store.updateRepo('r1', { projectHostSetupMethod: 'cloned' }) + store.updateRepo({ id: 'r1' }, { projectHostSetupMethod: 'cloned' }) expect(store.getRepo('r1')?.projectHostSetupMethod).toBe('cloned') expect(store.getProjectHostSetups()).toEqual([ @@ -474,13 +492,16 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - repoIcon: { - type: 'image', - source: 'upload', - src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' - } as never - }) + const updated = store.updateRepo( + { id: 'r1' }, + { + repoIcon: { + type: 'image', + source: 'upload', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' + } as never + } + ) expect(updated).not.toBeNull() expect(updated!.repoIcon).toBeUndefined() @@ -491,7 +512,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { badgeColor: ' ABCDEF ' }) + const updated = store.updateRepo({ id: 'r1' }, { badgeColor: ' ABCDEF ' }) expect(updated!.badgeColor).toBe('#abcdef') expect(store.getRepo('r1')!.badgeColor).toBe('#abcdef') @@ -501,7 +522,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ badgeColor: '#123456' })) - const updated = store.updateRepo('r1', { badgeColor: 'blue' }) + const updated = store.updateRepo({ id: 'r1' }, { badgeColor: 'blue' }) expect(updated!.badgeColor).toBe('#123456') expect(store.getRepo('r1')!.badgeColor).toBe('#123456') @@ -527,12 +548,15 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - upstream: { owner: ' stablyai ', repo: ' orca ' } - }) + const updated = store.updateRepo( + { id: 'r1' }, + { + upstream: { owner: ' stablyai ', repo: ' orca ' } + } + ) expect(updated!.upstream).toEqual({ owner: 'stablyai', repo: 'orca' }) - store.updateRepo('r1', { upstream: null }) + store.updateRepo({ id: 'r1' }, { upstream: null }) store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.upstream).toBeNull() @@ -542,20 +566,23 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - gitRemoteIdentity: { - canonicalKey: 'gitlab.example.com/team/orca', - remoteName: 'origin', - remoteUrl: 'git@gitlab.example.com:team/orca.git' + const updated = store.updateRepo( + { id: 'r1' }, + { + gitRemoteIdentity: { + canonicalKey: 'gitlab.example.com/team/orca', + remoteName: 'origin', + remoteUrl: 'git@gitlab.example.com:team/orca.git' + } } - }) + ) expect(updated!.gitRemoteIdentity).toEqual({ canonicalKey: 'gitlab.example.com/team/orca', remoteName: 'origin', remoteUrl: 'git@gitlab.example.com:team/orca.git' }) - store.updateRepo('r1', { gitRemoteIdentity: null }) + store.updateRepo({ id: 'r1' }, { gitRemoteIdentity: null }) store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.gitRemoteIdentity).toBeNull() @@ -573,9 +600,12 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - upstream: { owner: ' acme ', repo: ' widgets ', host: ' GHE.example:8443 ' } - }) + const updated = store.updateRepo( + { id: 'r1' }, + { + upstream: { owner: ' acme ', repo: ' widgets ', host: ' GHE.example:8443 ' } + } + ) expect(updated!.upstream).toEqual({ owner: 'acme', repo: 'widgets', @@ -608,14 +638,14 @@ describe('Store', () => { it('updateRepo returns null for nonexistent id', async () => { const store = await createStore() - expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull() + expect(store.updateRepo({ id: 'nope' }, { displayName: 'x' })).toBeNull() }) it('updateRepo persists issueSourcePreference across reloads', async () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { issueSourcePreference: 'upstream' }) + const updated = store.updateRepo({ id: 'r1' }, { issueSourcePreference: 'upstream' }) expect(updated!.issueSourcePreference).toBe('upstream') store.flush() @@ -627,16 +657,19 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - ghAccount: { host: ' GitHub.COM ', user: ' Alice ' } - }) + const updated = store.updateRepo( + { id: 'r1' }, + { + ghAccount: { host: ' GitHub.COM ', user: ' Alice ' } + } + ) expect(updated!.ghAccount).toEqual({ host: 'github.com', user: 'Alice' }) store.flush() const reloaded = await createStore() expect(reloaded.getRepo('r1')!.ghAccount).toEqual({ host: 'github.com', user: 'Alice' }) - const cleared = reloaded.updateRepo('r1', { ghAccount: null }) + const cleared = reloaded.updateRepo({ id: 'r1' }, { ghAccount: null }) expect(cleared!.ghAccount).toBeUndefined() }) @@ -644,7 +677,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { forkSyncMode: 'safe-auto' }) + const updated = store.updateRepo({ id: 'r1' }, { forkSyncMode: 'safe-auto' }) expect(updated!.forkSyncMode).toBe('safe-auto') store.flush() @@ -656,7 +689,8 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ forkSyncMode: 'ask' })) - const updated = store.updateRepo('r1', { forkSyncMode: 'always' as never }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: feeds the sanitizer a value its type forbids, on purpose. + const updated = store.updateRepo({ id: 'r1' }, { forkSyncMode: 'always' as never }) expect(updated!.forkSyncMode).toBe('ask') @@ -683,7 +717,7 @@ describe('Store', () => { expect(store.getRepo('r1')!.issueSourcePreference).toBe('origin') // Why: `Object.assign` skips undefined, so updateRepo needs an explicit delete branch or the key with value undefined wouldn't clear. - store.updateRepo('r1', { issueSourcePreference: undefined }) + store.updateRepo({ id: 'r1' }, { issueSourcePreference: undefined }) expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() store.flush() diff --git a/src/main/persistence-worktree-visibility.test.ts b/src/main/persistence-worktree-visibility.test.ts index 600a76effb2f..3fc49ab13360 100644 --- a/src/main/persistence-worktree-visibility.test.ts +++ b/src/main/persistence-worktree-visibility.test.ts @@ -75,7 +75,7 @@ describe('Store', () => { }) ) - const updated = store.updateRepo('r1', { externalWorktreeVisibility: 'hide' }) + const updated = store.updateRepo({ id: 'r1' }, { externalWorktreeVisibility: 'hide' }) expect(updated!.externalWorktreeVisibility).toBe('hide') expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) @@ -172,7 +172,7 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' })) - const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' }) + const updated = store.updateRepo({ id: 'r1' }, { agentWorktreeVisibility: 'show' }) expect(updated).toMatchObject({ externalWorktreeVisibility: 'hide', @@ -197,16 +197,19 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - customWorktreeVisibilitySources: [ - { id: 'team', rootPath: ' /srv/team-worktrees ' }, - { id: 'invalid', rootPath: '../relative' } - ], - worktreeVisibilitySourcePreferences: { - builtIn: { claude: 'show', gsd: 'hide' }, - custom: { team: 'show' } + const updated = store.updateRepo( + { id: 'r1' }, + { + customWorktreeVisibilitySources: [ + { id: 'team', rootPath: ' /srv/team-worktrees ' }, + { id: 'invalid', rootPath: '../relative' } + ], + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show', gsd: 'hide' }, + custom: { team: 'show' } + } } - }) + ) expect(updated).toMatchObject({ customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], @@ -232,7 +235,7 @@ describe('Store', () => { }) ) - const updated = store.updateRepo('r1', { externalWorktreeVisibility: null }) + const updated = store.updateRepo({ id: 'r1' }, { externalWorktreeVisibility: null }) expect(updated?.externalWorktreeVisibility).toBeUndefined() expect(updated?.externalWorktreeVisibilityLegacy).toBe(false) @@ -281,10 +284,13 @@ describe('Store', () => { }) ) - store.updateRepo('r1', { - issueSourcePreference: undefined, - sourceControlAi: undefined - }) + store.updateRepo( + { id: 'r1' }, + { + issueSourcePreference: undefined, + sourceControlAi: undefined + } + ) expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined() expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined() @@ -306,9 +312,12 @@ describe('Store', () => { }) ) - store.updateRepo('r1', { - sourceControlAi: null - }) + store.updateRepo( + { id: 'r1' }, + { + sourceControlAi: null + } + ) expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined() @@ -321,29 +330,32 @@ describe('Store', () => { const store = await createStore() store.addRepo(makeRepo()) - const updated = store.updateRepo('r1', { - sourceControlAi: { - instructionsByOperation: { - commitMessage: 'Repo style', - pullRequest: 42, - unknown: 'ignored' - }, - prCreationDefaults: { - draft: true, - useTemplate: null, - openAfterCreate: 'yes' - }, - modelOverridesByOperation: { - commitMessage: { - selectedModelByAgent: { codex: 'gpt-5.4', claude: false }, - selectedThinkingByModel: { 'gpt-5.4': 'high', bad: true } + const updated = store.updateRepo( + { id: 'r1' }, + { + sourceControlAi: { + instructionsByOperation: { + commitMessage: 'Repo style', + pullRequest: 42, + unknown: 'ignored' + }, + prCreationDefaults: { + draft: true, + useTemplate: null, + openAfterCreate: 'yes' }, - unknown: { - selectedModelByAgent: { codex: 'ignored' } + modelOverridesByOperation: { + commitMessage: { + selectedModelByAgent: { codex: 'gpt-5.4', claude: false }, + selectedThinkingByModel: { 'gpt-5.4': 'high', bad: true } + }, + unknown: { + selectedModelByAgent: { codex: 'ignored' } + } } - } - } as never - }) + } as never + } + ) expect(updated!.sourceControlAi).toEqual({ instructionsByOperation: { @@ -377,7 +389,8 @@ describe('Store', () => { }) ) - const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: feeds the sanitizer a value its type forbids, on purpose. + const updated = store.updateRepo({ id: 'r1' }, { sourceControlAi: 'bad' as never }) expect(updated!.sourceControlAi).toEqual({ instructionsByOperation: { commitMessage: 'Keep me' }, diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index 3fee959c9551..c6d8023f6d4d 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,9 +1,11 @@ import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill' import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import type { PtyRetirementEvidence } from '../../pty-exit-proof' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' import { pruneRetiredSshRemotePtyLeaseTombstones } from './ssh-pty-lease-tombstone-retention' -// Leases come only from relay-era profiles; this build retires them and never writes new ones. +// Leases come only from relay-era profiles; this build retires them and never creates new ones. export type SshPtyLeaseOperations = { state: PersistedState toStoredPtyId: (targetId: string, ptyId: string) => string @@ -21,14 +23,16 @@ export function getSshRemotePtyLeases( return leases.filter((lease) => targetId === undefined || lease.targetId === targetId) } -/** `terminated` is the only state written now: an operator close or a proven exit, which unbinds - * the pane. `expired` would say the client lost its route and must keep the binding. */ +/** Writes `terminated`, the only state written now: an operator close or a proven exit, which + * unbinds the pane. `expired` would say the client lost its route and must keep the binding. */ export function markSshRemotePtyLease( operations: SshPtyLeaseOperations, targetId: string, ptyId: string, - state: 'terminated' + // Why unread: the type makes each retirement name its evidence (src/main/pty-exit-proof.ts). + _evidence: PtyRetirementEvidence ): void { + const state = 'terminated' const relayPtyId = operations.toStoredPtyId(targetId, ptyId) const lease = operations.state.sshRemotePtyLeases?.find( (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId @@ -49,6 +53,29 @@ export function markSshRemotePtyLease( } } +/** The legacy relay bridge's record of a closed tab's undelivered stop; null clears it. */ +export function setSshRemotePtyLeasePendingKill( + operations: SshPtyLeaseOperations, + targetId: string, + ptyId: string, + pendingKill: SshPendingPtyKill | null +): void { + const relayPtyId = operations.toStoredPtyId(targetId, ptyId) + const lease = operations.state.sshRemotePtyLeases?.find( + (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId + ) + if (!lease || (!pendingKill && !lease.pendingKill)) { + return + } + if (pendingKill) { + lease.pendingKill = pendingKill + } else { + delete lease.pendingKill + } + lease.updatedAt = Date.now() + operations.flush() +} + export function removeSshRemotePtyLeases( operations: SshPtyLeaseOperations, targetId: string diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c274a883acc0..ea835138ae0d 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -11,6 +11,10 @@ import type { PtyBindingSourceExpectation } from './store' import type { StoreRuntimeState } from './store-runtime-state' import type { SessionHostPartitionOperations } from './session-host-partitions' +import { + withdrawUnstartedPane, + type UnstartedPaneKept +} from '../terminal-topology/unstarted-pane-withdraw' import { resolveHostId } from './session-host-partitions' import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' @@ -244,12 +248,17 @@ export class PtyBindingPersistenceOperations { ) } + /** With `stillStarting`, a pane whose terminal bound or is starting stays (UnstartedPaneKept). */ withdrawTerminalPane( pane: CommandOf<'closePane'>, - hostId?: string | null - ): Promise { + hostId?: string | null, + stillStarting?: () => boolean + ): Promise { + const resolved = resolveHostId(hostId) return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation( - withdrawPane(resolveHostId(hostId), pane, this.topology()) + stillStarting + ? withdrawUnstartedPane(resolved, pane, this.topology(), stillStarting) + : withdrawPane(resolved, pane, this.topology()) ) } diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 47c895224a6b..7bb43873d932 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -4,6 +4,8 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import type { TerminalSessionPartition } from '../../../shared/workspace-layout/terminal-owner-invariants' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' import type { PtyBindingSourceExpectation } from './store' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { isClosedPaneFenced } from '../terminal-topology/closed-pane-binding-fence' export type PtyBindingRefusalRequest = { tabId: string @@ -15,7 +17,7 @@ export type PtyBindingRefusalRequest = { } /** - * The five fences a binding must clear before anything is mutated, so a refusal leaves nothing + * The fences a binding must clear before anything is mutated, so a refusal leaves nothing * half-written. Order matters: every `false` here is returned before the write path or the * fast lane can run, which is what the relay's lease expiry and the stable-owner throw rely on. */ @@ -74,6 +76,17 @@ export function ptyBindingIsRefused( ) { return true } + // Switch point: a pane main closed or took back is not minted again by a start already under way. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + !layoutContainsLeafId( + session.terminalLayoutsByTabId?.[args.tabId]?.root ?? null, + args.leafId + ) && + isClosedPaneFenced(paneKey) + ) { + return true + } // Mirrors the four creating branches of the write path โ€” mint a tab, mint a root leaf, split // the root and graft a leaf, mint a layout โ€” each of which sets `terminalMembershipChanged`. if ( diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 439b2349c664..29a697cda59d 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -1,6 +1,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' +import type { RepoHostTarget } from '../../../shared/repo-host-identity' import type { GhAccountBinding } from '../../../shared/github/account-binding' import { removeRepoFromHostWorkspaceSessions, @@ -151,7 +152,7 @@ export class RepoLifecycleOperations { } updateRepo( - id: string, + target: RepoHostTarget, updates: Partial< Pick< Repo, @@ -185,10 +186,9 @@ export class RepoLifecycleOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null - }, - hostId?: ExecutionHostId + } ): Repo | null { - return getRepoUpdateOperations(this).updateRepo(id, updates, hostId) + return getRepoUpdateOperations(this).updateRepo(target, updates) } } @@ -274,7 +274,7 @@ export function getProjectHostSetupOperations( owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ state: owner[repoLifecycleOperationsContext].runtime.state, - updateRepo: (id, updates, hostId) => owner.updateRepo(id, updates, hostId), + updateRepo: (target, updates) => owner.updateRepo(target, updates), scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) }) return owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 5787fdcf2229..d1d3f24ce519 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -1,8 +1,11 @@ +import type { SshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill' import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import type { PtyRetirementEvidence } from '../../pty-exit-proof' import { getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, markSshRemotePtyLease as markSshRemotePtyLeaseOperation, removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + setSshRemotePtyLeasePendingKill as setSshRemotePtyLeasePendingKillOperation, type SshPtyLeaseOperations } from '../leasing-ssh-ptys/ssh-pty-lease-operations' import { @@ -54,8 +57,21 @@ export class SshLeaseRecoveryOperations { ) } - markSshRemotePtyLease(targetId: string, ptyId: string, state: 'terminated'): void { - markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, state) + markSshRemotePtyLease(targetId: string, ptyId: string, evidence: PtyRetirementEvidence): void { + markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, evidence) + } + + setSshRemotePtyLeasePendingKill( + targetId: string, + ptyId: string, + pendingKill: SshPendingPtyKill | null + ): void { + setSshRemotePtyLeasePendingKillOperation( + getSshPtyLeaseOperations(this), + targetId, + ptyId, + pendingKill + ) } removeSshRemotePtyLeases(targetId: string): void { diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index c11d28d41786..6cddd3d6564f 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -74,6 +74,11 @@ export class StoreRuntimeState { quitFlushPromise: Promise | null = null lastWrittenStateHash: string | null = null lastDurableWriteGeneration = -1 + /** + * The write generation each migration committed by this process needs on disk before its + * received blobs may be consumed; a receipt missing here was read from disk. + */ + readonly orcadMigrationCommitWriteGenerations = new Map() firstPendingSaveAt: number | null = null /** Known dirty domains, or null when a caller requires a complete-document fallback. */ dirtyProfileStateDomains: Set | null = new Set() diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts index 9b116791d957..dbbdb34ad941 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -30,7 +30,9 @@ import { expireOrcadMigrationStages, findOrcadMigrationImportReceipt, isLiveOrcadMigrationStage, - recordOrcadMigrationImportReceipt + recordOrcadMigrationCommitWriteGeneration, + recordOrcadMigrationImportReceipt, + type OrcadMigrationCommitGenerationRuntime } from './orcad-catalog-receipt-ledger' import { abortOrcadMigrationSnapshots, @@ -50,11 +52,10 @@ import { stageOrcadMigrationBlobChunk } from './orcad-migration-blob-staging' import { assertOrcadMigrationBlobContentsValid } from './orcad-migration-blob-installers' +import { readDeclaredOrcadMigrationEditorDrafts } from './orcad-destination-editor-drafts' -type OrcadCatalogImportRuntime = Pick< - StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' | 'orcadMigrationBlobRoot' -> +type OrcadCatalogImportRuntime = OrcadMigrationCommitGenerationRuntime & + Pick const orcadCatalogImportContext = Symbol('OrcadCatalogImportPersistence') type OrcadCatalogImportContext = { @@ -159,21 +160,25 @@ export class OrcadCatalogImportPersistence { if (current.state !== 'staged') { throw new Error('orcad_migration_catalog_not_staged') } - const prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + let prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state) + const blobRoot = context.runtime.orcadMigrationBlobRoot assertOrcadMigrationSnapshotsReady(manifest, context.runtime.terminalScrollbackSnapshotStorage) if (options.expectedBlobs) { - assertOrcadMigrationBlobSetMatches( - manifest, - context.runtime.orcadMigrationBlobRoot, - options.expectedBlobs - ) + assertOrcadMigrationBlobSetMatches(manifest, blobRoot, options.expectedBlobs) + } + assertOrcadMigrationBlobsReady(manifest, blobRoot) + assertOrcadMigrationBlobContentsValid(manifest, blobRoot) + // Before any blob moves, so a refused draft or conflict leaves staging as it was. + const editorDrafts = readDeclaredOrcadMigrationEditorDrafts(manifest, blobRoot) + if (editorDrafts) { + prepared = prepareOrcadMigrationCatalog(manifest, context.runtime.state, editorDrafts) } - assertOrcadMigrationBlobsReady(manifest, context.runtime.orcadMigrationBlobRoot) - assertOrcadMigrationBlobContentsValid(manifest, context.runtime.orcadMigrationBlobRoot) commitOrcadMigrationSnapshots(manifest, context.runtime.terminalScrollbackSnapshotStorage) - commitOrcadMigrationBlobs(manifest, context.runtime.orcadMigrationBlobRoot) + commitOrcadMigrationBlobs(manifest, blobRoot) const receipt = commitPreparedCatalog(context, manifest, prepared, options.now) + const generationBefore = context.runtime.writeGeneration scheduleSave(context.scheduling) + recordOrcadMigrationCommitWriteGeneration(context.runtime, manifest, generationBefore) return committedCatalogState(receipt) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts index dc0cbbb3401a..ed70f316d8d1 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-receipt-ledger.ts @@ -10,6 +10,11 @@ import type { } from '../../../shared/orcad-migration/orcad-migration-manifest' import { MAX_ORCAD_MIGRATION_EVICTED_RECEIPTS } from '../../../shared/orcad-migration/orcad-migration-evicted-receipts' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import { + orcadMigrationStagingKey, + type OrcadMigrationRequestIdentity +} from './orcad-migration-staged-file' // A client resumes a stage within minutes; a week only covers a host left offline meanwhile. export const ORCAD_MIGRATION_STAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000 @@ -76,3 +81,43 @@ export function expireOrcadMigrationStages(state: PersistedState, now: number): state.orcadMigrationStagedCatalogs = live return true } + +export type OrcadMigrationCommitGenerationRuntime = Pick< + StoreRuntimeState, + 'writeGeneration' | 'lastDurableWriteGeneration' | 'orcadMigrationCommitWriteGenerations' +> + +/** Call right after the commit's `scheduleSave`; `generationBefore` is read just before it. */ +export function recordOrcadMigrationCommitWriteGeneration( + runtime: OrcadMigrationCommitGenerationRuntime, + migration: OrcadMigrationRequestIdentity, + generationBefore: number +): void { + // A save suppressed by quit or maintenance never bumps the generation, so no in-process write + // can prove this commit; only a restart that reads it from disk does. + runtime.orcadMigrationCommitWriteGenerations.set( + orcadMigrationStagingKey(migration), + runtime.writeGeneration > generationBefore ? runtime.writeGeneration : Number.POSITIVE_INFINITY + ) +} + +/** + * Whether the receipt is on disk. A catalog-state query can see an in-memory receipt whose flush + * failed; consuming its received blobs then would leave a retry after restart without them. + */ +export function isOrcadMigrationCommitDurable( + runtime: OrcadMigrationCommitGenerationRuntime, + migration: OrcadMigrationRequestIdentity +): boolean { + const key = orcadMigrationStagingKey(migration) + const required = runtime.orcadMigrationCommitWriteGenerations.get(key) + if (required === undefined) { + return true + } + if (runtime.lastDurableWriteGeneration < required) { + return false + } + // Durable stays durable, so the entry is no longer needed. + runtime.orcadMigrationCommitWriteGenerations.delete(key) + return true +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts index f70f9815afb5..8e5fbabb5187 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-records.ts @@ -4,6 +4,7 @@ import { type OrcadMigrationImportReceipt, type OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import type { OrcadMigrationEditorDraft } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import type { ProjectGroup } from '../../../shared/project-group-types' import type { Repo } from '../../../shared/repo-types' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' @@ -48,7 +49,8 @@ export function assertSameOrcadMigrationManifest( // Dormant only: no live PTY reaches the destination, so the incoming session is compared as is. export function prepareOrcadMigrationCatalog( manifest: OrcadMigrationManifest, - state: StoreRuntimeState['state'] + state: StoreRuntimeState['state'], + editorDrafts?: readonly OrcadMigrationEditorDraft[] ): PreparedOrcadMigrationCatalog { assertOrcadMigrationStagedCatalogClaims(manifest, state.orcadMigrationStagedCatalogs ?? []) const repositories = manifest.payload.repositories.map(toOrcadDestinationRepository) @@ -77,7 +79,7 @@ export function prepareOrcadMigrationCatalog( folderWorkspaces, existingProjectGroups: state.projectGroups }) - const dormantState = prepareOrcadMigrationDormantState(manifest, state) + const dormantState = prepareOrcadMigrationDormantState(manifest, state, editorDrafts) return { repositories, projectGroups, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts new file mode 100644 index 000000000000..f62ca126dc20 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-editor-drafts.ts @@ -0,0 +1,151 @@ +/** + * Destination side of the `editor-drafts` blob. The drafts are applied inside the catalog commit, + * never after it: the commit verifies the bytes against the signed declaration, writes them into + * the incoming session, and runs the full session conflict check on that hydrated copy. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + sameOrcadMigrationBlob, + type OrcadMigrationBlobDescriptor +} from '../../../shared/orcad-migration/orcad-migration-blob-transfer' +import { + ORCAD_MIGRATION_EDITOR_DRAFTS_KEY, + orcadMigrationEditorDraftIdentity, + parseOrcadMigrationEditorDraftsBytes, + type OrcadMigrationEditorDraft, + type OrcadMigrationEditorDraftEntry +} from '../../../shared/orcad-migration/orcad-migration-editor-drafts' +import type { + OrcadMigrationDormantStatePayload, + OrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + getOrcadMigrationReceivedBlobPath, + listOrcadMigrationReceivedBlobNames, + stagedOrcadMigrationBlobFiles +} from './orcad-migration-blob-staging' +import { + assertNoOrcadMigrationSessionConflicts, + prepareOrcadMigrationWorkspaceSession, + type PreparedOrcadMigrationWorkspaceSession +} from './orcad-destination-workspace-session' + +/** + * The declared drafts, read from the staged bytes on a first commit or the received bytes on a + * retry. `expectedBlobs` is optional on the wire, so this refuses a declared-but-absent blob itself. + */ +export function readDeclaredOrcadMigrationEditorDrafts( + manifest: OrcadMigrationManifest, + root: string +): OrcadMigrationEditorDraft[] | undefined { + const declaration = manifest.payload.dormantState?.editorDraftsBlob + const staged = stagedOrcadMigrationBlobFiles(manifest, root).filter( + (file) => file.descriptor.blobKind === 'editor-drafts' + ) + const received = listOrcadMigrationReceivedBlobNames(root, manifest, 'editor-drafts') + if (!declaration) { + if (staged.length > 0 || received.length > 0) { + throw new Error('orcad_migration_editor_drafts_undeclared') + } + return undefined + } + const descriptor: OrcadMigrationBlobDescriptor = { + blobKind: 'editor-drafts', + key: ORCAD_MIGRATION_EDITOR_DRAFTS_KEY, + sha256: declaration.sha256, + byteLength: declaration.byteLength + } + if (staged.some((file) => !sameOrcadMigrationBlob(file.descriptor, descriptor))) { + throw new Error('orcad_migration_editor_drafts_digest_mismatch') + } + const path = staged[0]?.path ?? getOrcadMigrationReceivedBlobPath(root, manifest, descriptor) + let bytes: Buffer + try { + bytes = readFileSync(path) + } catch { + throw new Error('orcad_migration_editor_drafts_missing') + } + // Hash what is parsed, so nothing can change between the check and the read. + if ( + bytes.length !== declaration.byteLength || + createHash('sha256').update(bytes).digest('hex') !== declaration.sha256 + ) { + throw new Error('orcad_migration_editor_drafts_digest_mismatch') + } + return parseOrcadMigrationEditorDraftsBytes(bytes, declaration) +} + +/** + * Staging has no draft bytes yet, so declared entries are compared without their draft on both + * sides; the commit reruns the full check on the hydrated session and replaces this result. + */ +export function prepareOrcadMigrationSessionWithDrafts( + payload: OrcadMigrationDormantStatePayload, + state: PersistedState, + drafts: readonly OrcadMigrationEditorDraft[] | undefined +): PreparedOrcadMigrationWorkspaceSession { + const declaration = payload.editorDraftsBlob + const incoming = payload.workspaceSession + if (!declaration || !incoming) { + return prepareOrcadMigrationWorkspaceSession(incoming, state) + } + if (drafts) { + return prepareOrcadMigrationWorkspaceSession(withEditorDrafts(incoming, drafts), state) + } + assertNoOrcadMigrationSessionConflicts( + withoutEditorDrafts(state.workspaceSession, declaration.entries), + incoming + ) + return { incoming, merged: undefined, editorDraftsPending: true } +} + +/** A detached copy of `session` with each draft written into its one declared editor entry. */ +function withEditorDrafts( + session: WorkspaceSessionState, + drafts: readonly OrcadMigrationEditorDraft[] +): WorkspaceSessionState { + const hydrated = structuredClone(session) + for (const draft of drafts) { + const file = hydrated.openFilesByWorktree?.[draft.worktreeKey]?.find( + (entry) => entry.filePath === draft.filePath + ) + if (!file) { + throw new Error('orcad_migration_editor_drafts_declaration_scope_invalid') + } + file.dirtyDraftContent = draft.content + } + return hydrated +} + +function withoutEditorDrafts( + session: WorkspaceSessionState, + entries: readonly OrcadMigrationEditorDraftEntry[] +): WorkspaceSessionState { + const declared = new Set(entries.map(orcadMigrationEditorDraftIdentity)) + const openFiles = session.openFilesByWorktree + if (!openFiles) { + return session + } + return { + ...session, + openFilesByWorktree: Object.fromEntries( + Object.entries(openFiles).map(([worktreeKey, files]) => [ + worktreeKey, + files.map((file) => { + if ( + !declared.has( + orcadMigrationEditorDraftIdentity({ worktreeKey, filePath: file.filePath }) + ) + ) { + return file + } + const { dirtyDraftContent: _draft, ...rest } = file + return rest + }) + ]) + ) + } +} diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts index 92727ebecceb..611a39974385 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-destination-workspace-session.ts @@ -32,6 +32,8 @@ const MERGED_SESSION_RECORD_FIELDS = [ export type PreparedOrcadMigrationWorkspaceSession = { incoming: WorkspaceSessionState | undefined merged: WorkspaceSessionState | undefined + /** Checked at staging without the declared drafts; never applied, or they would be lost. */ + editorDraftsPending?: true } export function prepareOrcadMigrationWorkspaceSession( @@ -41,15 +43,22 @@ export function prepareOrcadMigrationWorkspaceSession( if (!incoming) { return { incoming: undefined, merged: undefined } } - assertNoSessionRecordConflicts(state.workspaceSession, incoming) - assertNoSessionFocusConflicts(state.workspaceSession, incoming) - assertNoSessionEntityConflicts(state.workspaceSession, incoming) + assertNoOrcadMigrationSessionConflicts(state.workspaceSession, incoming) return { incoming, merged: mergeWorkspaceSessions(state.workspaceSession, incoming) } } +export function assertNoOrcadMigrationSessionConflicts( + existing: WorkspaceSessionState, + incoming: WorkspaceSessionState +): void { + assertNoSessionRecordConflicts(existing, incoming) + assertNoSessionFocusConflicts(existing, incoming) + assertNoSessionEntityConflicts(existing, incoming) +} + function assertNoSessionFocusConflicts( existing: WorkspaceSessionState, incoming: WorkspaceSessionState @@ -73,6 +82,9 @@ export function applyPreparedOrcadMigrationWorkspaceSession( prepared: PreparedOrcadMigrationWorkspaceSession, state: PersistedState ): void { + if (prepared.editorDraftsPending) { + throw new Error('orcad_migration_editor_drafts_not_applied') + } if (prepared.merged) { state.workspaceSession = prepared.merged } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts index 62f5749c101d..60bf49ec4425 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-dormant-state-records.ts @@ -16,9 +16,10 @@ import { import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' import { applyPreparedOrcadMigrationWorkspaceSession, - prepareOrcadMigrationWorkspaceSession, type PreparedOrcadMigrationWorkspaceSession } from './orcad-destination-workspace-session' +import { prepareOrcadMigrationSessionWithDrafts } from './orcad-destination-editor-drafts' +import type { OrcadMigrationEditorDraft } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import { applyPreparedOrcadMigrationAutomationState, prepareOrcadMigrationAutomationState, @@ -46,9 +47,11 @@ export type PreparedOrcadMigrationDormantState = { clientState: PreparedOrcadMigrationClientState } +/** `editorDrafts`: the verified declared drafts at commit; absent while staging. */ export function prepareOrcadMigrationDormantState( manifest: OrcadMigrationManifest, - state: PersistedState + state: PersistedState, + editorDrafts?: readonly OrcadMigrationEditorDraft[] ): PreparedOrcadMigrationDormantState { const payload = manifest.payload.dormantState if (!payload) { @@ -116,7 +119,7 @@ export function prepareOrcadMigrationDormantState( entry.registry ) })), - workspaceSession: prepareOrcadMigrationWorkspaceSession(payload.workspaceSession, state), + workspaceSession: prepareOrcadMigrationSessionWithDrafts(payload, state, editorDrafts), automationState: prepareOrcadMigrationAutomationState( payload.automations, payload.automationRuns, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts index cba07a969eba..aad338b98d04 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-install-persistence.ts @@ -4,7 +4,10 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { parsePaneKey } from '../../../shared/stable-pane-id' import { getHistoryRoot } from '../../terminal-history-paths' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' -import { findOrcadMigrationImportReceipt } from './orcad-catalog-receipt-ledger' +import { + findOrcadMigrationImportReceipt, + isOrcadMigrationCommitDurable +} from './orcad-catalog-receipt-ledger' import { installOrcadMigrationReceivedBlobs } from './orcad-migration-blob-installers' import { listOrcadMigrationReceivedBlobNames } from './orcad-migration-blob-staging' import type { OrcadMigrationRequestIdentity } from './orcad-migration-staged-file' @@ -16,6 +19,9 @@ type OrcadMigrationBlobInstallRuntime = Pick< | 'orcadTerminalRestoreHints' | 'paneShellPath' | 'deferredShellHistoryWorktreeIds' + | 'writeGeneration' + | 'lastDurableWriteGeneration' + | 'orcadMigrationCommitWriteGenerations' > type ClockOptions = { now?: () => Date } @@ -38,9 +44,9 @@ export class OrcadMigrationBlobInstallPersistence { } /** - * Run only once the commit is durable: installing first and then losing the commit would leave - * live hints or history for a catalog this host never received. A no-op before commit and on - * repeats. + * Installs only once the commit is durable: installing first and then losing the commit would + * leave live state for a catalog this host never received, and consume the received blobs a + * retry after restart needs. A no-op before then and on repeats. */ installOrcadMigrationReceivedBlobs( manifest: OrcadMigrationManifest, @@ -127,6 +133,9 @@ function installCommitted( migration: OrcadMigrationRequestIdentity, options: InstallOptions ): void { + if (!isOrcadMigrationCommitDurable(runtime, migration)) { + return + } installOrcadMigrationReceivedBlobs({ migration, root: runtime.orcadMigrationBlobRoot, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts index 32c771cdb29d..808418aae4e4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-installers.ts @@ -3,10 +3,11 @@ * commits, and an installer after the commit is durable that moves the received blob into live * state and deletes it. Kinds without an installer are not advertised, so none arrive. */ -import { readFileSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import type { OrcadMigrationBlobKind } from '../../../shared/orcad-migration/orcad-migration-blob-transfer' import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { ORCAD_MIGRATION_EDITOR_DRAFTS_KEY } from '../../../shared/orcad-migration/orcad-migration-editor-drafts' import { ORCAD_MIGRATION_TERMINAL_RESTORE_HINTS_KEY, parseOrcadMigrationTerminalRestoreHintsText, @@ -101,7 +102,11 @@ export type OrcadMigrationBlobInstall = { export function installOrcadMigrationReceivedBlobs(install: OrcadMigrationBlobInstall): void { let firstError: unknown = null // One kind's failure must not hold back the others; each keeps its own blobs for the retry. - for (const installKind of [installTerminalRestoreHints, installShellHistory]) { + for (const installKind of [ + installTerminalRestoreHints, + installShellHistory, + releaseEditorDrafts + ]) { try { installKind(install) } catch (error) { @@ -128,6 +133,19 @@ function installShellHistory(install: OrcadMigrationBlobInstall): void { }) } +// The drafts landed in the committed session; this file only served a commit retry. +function releaseEditorDrafts(install: OrcadMigrationBlobInstall): void { + const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { + blobKind: 'editor-drafts', + key: ORCAD_MIGRATION_EDITOR_DRAFTS_KEY + }) + if (!existsSync(path)) { + return + } + rmSync(path, { force: true }) + syncDirectoryDurablySync(dirname(path)) +} + function installTerminalRestoreHints(install: OrcadMigrationBlobInstall): void { const path = getOrcadMigrationReceivedBlobPath(install.root, install.migration, { blobKind: 'terminal-restore-hints', diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts index c6cbbd497181..290864358542 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-migration-blob-staging.ts @@ -56,7 +56,8 @@ const receivedDirectoryPath: ReceivedBlobPathResolver = (root, manifest, descrip const RECEIVED_BLOB_DESTINATIONS: Record = { 'terminal-restore-hints': receivedDirectoryPath, 'shell-history': receivedDirectoryPath, - 'agent-session-records': receivedDirectoryPath + 'agent-session-records': receivedDirectoryPath, + 'editor-drafts': receivedDirectoryPath } export function getOrcadMigrationReceivedBlobPath( diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts index 3d0a39705dce..4553f76b834e 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts @@ -15,6 +15,10 @@ import { isWslUncPath } from '../../../shared/wsl-paths' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { onWslMoveJournalChange } from './wsl-move-cutover-journal' import { resolveWslMoveFence, type WslMoveFenceState } from './wsl-move-fence' +import { + findProjectHostSetup, + type ProjectHostSetupTarget +} from '../../../shared/project-host-setup-target' export type WslMoveProjectRef = { repoId: string } | { folderWorkspaceId: string } @@ -221,12 +225,12 @@ export function assertWslMoveHostSetupWritable( assertWslMoveReposWritable(store, project?.sourceRepoIds ?? []) } -/** By id, for update and delete; an unknown id is left to the store's own not-found. */ +/** For update and delete; an unknown setup is left to the store's own not-found. */ export function assertWslMoveHostSetupIdWritable( store: HostSetupStore | undefined, - setupId: string + target: ProjectHostSetupTarget ): void { - const setup = store?.getProjectHostSetups?.().find((entry) => entry.id === setupId) + const setup = findProjectHostSetup(store?.getProjectHostSetups?.() ?? [], target) if (setup) { assertWslMoveHostSetupWritable(store, setup) } diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts index 2f4dade139cf..c0f0d7cc8525 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.test.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.test.ts @@ -4,6 +4,7 @@ import { ProfileStateIndeterminateWriteError, withProfileStateWriteTransaction } from './profile-state-write-transaction' +import { profileStateWriterFailureOutcome } from './profile-state-writer-errors' describe('profile state write transaction ownership', () => { it('preserves SQLITE_FULL after SQLite rolls back the transaction itself', () => { @@ -26,6 +27,67 @@ describe('profile state write transaction ownership', () => { } }) + // What a WAL commit on a full disk reports: node:sqlite's SQLITE_FULL after SQLite rolled back + // (reproduced on a 2 MB tmpfs: COMMIT throws errcode 13, isTransaction is false, rows unchanged). + function failCommitAfterAutoRollback(db: Database, failure: Error): () => void { + const exec = db.exec.bind(db) + const injected = vi.spyOn(db, 'exec').mockImplementation((sql) => { + if (sql === 'COMMIT') { + exec('ROLLBACK') + throw failure + } + exec(sql) + }) + return () => injected.mockRestore() + } + + it('reports a commit that ran out of disk as a failed write the next write can follow', () => { + const db = new Database(':memory:') + db.exec('CREATE TABLE writes (id INTEGER)') + const full = Object.assign(new Error('database or disk is full'), { + code: 'ERR_SQLITE_ERROR', + errcode: 13 + }) + const restore = failCommitAfterAutoRollback(db, full) + let thrown: unknown + try { + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (1)')) + } catch (error) { + thrown = error + } finally { + restore() + } + try { + expect(thrown).toBe(full) + expect(profileStateWriterFailureOutcome(thrown)).toBe('known-failure') + expect(db.isTransaction).toBe(false) + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 0 }) + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (2)')) + expect(db.prepare('SELECT COUNT(*) AS count FROM writes').get()).toMatchObject({ count: 1 }) + } finally { + db.close() + } + }) + + it('keeps any other failed commit indeterminate even after SQLite rolled back', () => { + const db = new Database(':memory:') + db.exec('CREATE TABLE writes (id INTEGER)') + // e.g. a failed fsync: the commit frame may already be on disk. + const ioerr = Object.assign(new Error('disk I/O error'), { + code: 'ERR_SQLITE_ERROR', + errcode: 1034 + }) + const restore = failCommitAfterAutoRollback(db, ioerr) + try { + expect(() => + withProfileStateWriteTransaction(db, () => db.exec('INSERT INTO writes VALUES (1)')) + ).toThrow(ProfileStateIndeterminateWriteError) + } finally { + restore() + db.close() + } + }) + it('rolls back a failed deferred commit and leaves the connection usable', () => { const db = new Database(':memory:') try { diff --git a/src/main/persistence/profile-state/profile-state-write-transaction.ts b/src/main/persistence/profile-state/profile-state-write-transaction.ts index 1dbf10d5bed1..93dcd3e6fc35 100644 --- a/src/main/persistence/profile-state/profile-state-write-transaction.ts +++ b/src/main/persistence/profile-state/profile-state-write-transaction.ts @@ -1,4 +1,5 @@ import type Database from '../../sqlite/sync-database' +import { storageCapacityErrorCode } from '../../../shared/storage-capacity-error' export class ProfileStateIndeterminateWriteError extends Error { readonly code = 'profile-state-write-indeterminate' as const @@ -26,8 +27,10 @@ export function withProfileStateWriteTransaction(db: Database.Database, write return result } catch (error) { if (!db.isTransaction) { - // SQLite can roll back a failed statement itself; a failed COMMIT is ambiguous. - if (committing) { + // SQLite can roll back a failed statement itself; a failed COMMIT is ambiguous, except + // SQLITE_FULL: with powersafe overwrite (the default VFS) the WAL commit frame is the last + // write, so a write that ran out of room means no commit frame landed. + if (committing && storageCapacityErrorCode(error) !== 'SQLITE_FULL') { throw new ProfileStateIndeterminateWriteError(error, undefined) } throw error diff --git a/src/main/persistence/repo-host-target-matcher.test-fixture.ts b/src/main/persistence/repo-host-target-matcher.test-fixture.ts new file mode 100644 index 000000000000..c182c45bf673 --- /dev/null +++ b/src/main/persistence/repo-host-target-matcher.test-fixture.ts @@ -0,0 +1,12 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import type { RepoHostTarget } from '../../shared/repo-host-identity' + +/** Matches an `updateRepo` target for `id` on `hostId`, however the target spells its host. */ +export function repoTarget(id: string, hostId: ExecutionHostId = 'local') { + return { + asymmetricMatch: (target: RepoHostTarget) => + target?.id === id && getRepoExecutionHostId(target) === hostId, + toString: () => `repoTarget(${id}, ${hostId})`, + toAsymmetricMatcher: () => `repoTarget(${id}, ${hostId})` + } +} diff --git a/src/main/persistence/terminal-topology/closed-pane-binding-fence.test.ts b/src/main/persistence/terminal-topology/closed-pane-binding-fence.test.ts new file mode 100644 index 000000000000..188b8db9f223 --- /dev/null +++ b/src/main/persistence/terminal-topology/closed-pane-binding-fence.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { + _resetClosedPaneFencesForTests, + fencedIfLayoutOwner, + isClosedPaneFenced, + partitionsFence +} from './closed-pane-binding-fence' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const LOCAL: ExecutionHostId = 'local' +const SSH: ExecutionHostId = 'ssh:box' + +function sessionWith(tabId: string, leafIds: string[]): WorkspaceSessionState { + const [first, second] = leafIds + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: first + ? { + [tabId]: { + root: second + ? { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: first }, + second: { type: 'leaf', leafId: second } + } + : { type: 'leaf', leafId: first }, + activeLeafId: first, + expandedLeafId: null + } + } + : {} + } +} + +function partitions(initial: Partial>) { + const sessions = new Map(Object.entries(initial)) + return { + get: (hostId: ExecutionHostId) => sessions.get(hostId), + set: (hostId: ExecutionHostId, session: WorkspaceSessionState) => sessions.set(hostId, session) + } +} + +describe('the closed-pane fence', () => { + afterEach(() => _resetClosedPaneFencesForTests()) + + it('a write elsewhere that only keeps a fenced pane does not lift its fence', () => { + // An old relay reattach left the tab in both partitions; the SSH copy is taken back. + const store = partitions({ + [SSH]: sessionWith('t', ['a', 'b']), + [LOCAL]: sessionWith('t', ['a', 'b']) + }) + fencedIfLayoutOwner( + () => partitionsFence([SSH], store.get, true), + () => (store.set(SSH, sessionWith('t', ['a'])), { value: null }) + )() + expect(isClosedPaneFenced('t:b')).toBe(true) + + fencedIfLayoutOwner( + () => partitionsFence([LOCAL], store.get, false), + () => (store.set(LOCAL, sessionWith('t', ['a', 'b'])), { value: null }) + )() + expect(isClosedPaneFenced('t:b')).toBe(true) + }) + + it('a rolled-back close lifts the fence it wrote, and a rolled-back create restores the one it lifted', () => { + const store = partitions({ [LOCAL]: sessionWith('t', ['a', 'b']) }) + const close = fencedIfLayoutOwner( + () => partitionsFence([LOCAL], store.get, true), + () => (store.set(LOCAL, sessionWith('t', ['a'])), { value: null, rollback: () => {} }) + )() + expect(isClosedPaneFenced('t:b')).toBe(true) + close.rollback?.() + store.set(LOCAL, sessionWith('t', ['a', 'b'])) + expect(isClosedPaneFenced('t:b')).toBe(false) + + fencedIfLayoutOwner( + () => partitionsFence([LOCAL], store.get, true), + () => (store.set(LOCAL, sessionWith('t', ['a'])), { value: null }) + )() + const create = fencedIfLayoutOwner( + () => partitionsFence([LOCAL], store.get, false), + () => (store.set(LOCAL, sessionWith('t', ['a', 'b'])), { value: null, rollback: () => {} }) + )() + expect(isClosedPaneFenced('t:b')).toBe(false) + create.rollback?.() + expect(isClosedPaneFenced('t:b')).toBe(true) + }) + + it('records nothing for an unfenced command', () => { + const store = partitions({ [LOCAL]: sessionWith('t', ['a', 'b']) }) + fencedIfLayoutOwner( + () => null, + () => (store.set(LOCAL, sessionWith('t', ['a'])), { value: null }) + )() + expect(isClosedPaneFenced('t:b')).toBe(false) + }) +}) diff --git a/src/main/persistence/terminal-topology/closed-pane-binding-fence.ts b/src/main/persistence/terminal-topology/closed-pane-binding-fence.ts new file mode 100644 index 000000000000..4f754a149f0d --- /dev/null +++ b/src/main/persistence/terminal-topology/closed-pane-binding-fence.ts @@ -0,0 +1,166 @@ +// Panes main closed or took back, kept in memory so a terminal start already under way cannot bring +// one back by its late binding (pty-binding-session-update.ts mints a missing pane). Layout-owner +// build only: every caller is behind ORCA_LAYOUT_OWNER_IS_MAIN. + +import type { ExecutionHostId } from '../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { collectLayoutLeafIdsInOrder } from '../../../shared/workspace-layout/terminal-pane-tree' +import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' + +/** + * Why 5 min: past the longest a start can wait before it binds. An SSH start may first wait on a + * credential prompt (SSH_CREDENTIAL_TIMEOUT_MS, 120 s) and a connect (CONNECT_TIMEOUT_MS, 30 s, + * both ssh-connection-utils.ts), then on its relay request (REQUEST_TIMEOUT_MS, 30 s, + * ssh-channel-multiplexer.ts); the local daemon admits a spawn within 12 s + * (MAX_SPAWN_ADMISSION_WINDOW_MS, daemon-terminal-admission.ts). Pane ids are never reused, so a + * longer fence refuses nothing legitimate; the bound only limits memory. + */ +export const CLOSED_PANE_BINDING_FENCE_MS = 5 * 60_000 + +const closedAtByPaneKey = new Map() + +type SessionsRead = { + hostIds: () => readonly ExecutionHostId[] + getSession: (hostId: ExecutionHostId) => WorkspaceSessionState | null | undefined +} + +type Fence = { sessions: SessionsRead; fences: boolean } + +function paneKeysIn( + hostIds: readonly ExecutionHostId[], + getSession: SessionsRead['getSession'] +): Set { + const keys = new Set() + for (const hostId of hostIds) { + const layouts = getSession(hostId)?.terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(layouts)) { + if (layout?.root) { + for (const leafId of collectLayoutLeafIdsInOrder(layout.root)) { + keys.add(`${tabId}:${leafId}`) + } + } + } + } + return keys +} + +/** + * Switch point: in a layout-owner build, `mutate` runs through `fencingRemovedPanes` with what `fence` + * names (null: unfenced). Otherwise it is `mutate` itself; `fence` is never called. + */ +export function fencedIfLayoutOwner( + fence: () => Fence | null, + mutate: () => DurableProfileStateMutation +): () => DurableProfileStateMutation { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return mutate + } + return () => { + const plan = fence() + return plan ? fencingRemovedPanes(plan.sessions, plan.fences, mutate) : mutate() + } +} + +/** A write to these partitions: `fences` when it is a close or take-back, else it only lifts. */ +export function partitionsFence( + hostIds: readonly ExecutionHostId[], + getSession: SessionsRead['getSession'], + fences: boolean +): Fence { + return { sessions: { hostIds: () => hostIds, getSession }, fences } +} + +/** A window or client layout command: its closes fence, its terminal creates lift; others neither. */ +export function layoutCommandFence( + type: LayoutCommand['type'], + hostIds: readonly ExecutionHostId[], + getSession: SessionsRead['getSession'] +): Fence | null { + if (type === 'closePane' || type === 'closeTabs') { + return partitionsFence(hostIds, getSession, true) + } + if (type === 'createTerminalTab' || type === 'splitPane') { + return partitionsFence(hostIds, getSession, false) + } + return null +} + +/** + * Runs a write of main's, then fences the panes it removed when `fences` (a close or take-back) and + * lifts the fence of each pane it newly wrote: a real create under the same id binds as any. A + * rolled-back write puts back the fences it changed. + */ +function fencingRemovedPanes( + sessions: SessionsRead, + fences: boolean, + write: () => DurableProfileStateMutation +): DurableProfileStateMutation { + // The partitions as they were before: a tab's close can drop the partition list it came from. + const hostIds = [...sessions.hostIds()] + const before = paneKeysIn(hostIds, sessions.getSession) + const result = write() + const after = paneKeysIn(hostIds, sessions.getSession) + const now = Date.now() + const changed = new Map() + const set = (paneKey: string, closedAt: number | undefined): void => { + if (!changed.has(paneKey)) { + changed.set(paneKey, closedAtByPaneKey.get(paneKey)) + } + if (closedAt === undefined) { + closedAtByPaneKey.delete(paneKey) + } else { + closedAtByPaneKey.set(paneKey, closedAt) + } + } + for (const [paneKey, closedAt] of closedAtByPaneKey) { + if ( + (after.has(paneKey) && !before.has(paneKey)) || + now - closedAt >= CLOSED_PANE_BINDING_FENCE_MS + ) { + set(paneKey, undefined) + } + } + if (fences) { + for (const paneKey of before) { + if (!after.has(paneKey)) { + set(paneKey, now) + } + } + } + if (changed.size === 0 || !result.rollback) { + return result + } + const rollback = result.rollback + return { + ...result, + rollback: () => { + rollback() + for (const [paneKey, closedAt] of changed) { + if (closedAt === undefined) { + closedAtByPaneKey.delete(paneKey) + } else { + closedAtByPaneKey.set(paneKey, closedAt) + } + } + } + } +} + +/** Whether main closed or took back this pane within the binding window. */ +export function isClosedPaneFenced(paneKey: string): boolean { + const closedAt = closedAtByPaneKey.get(paneKey) + if (closedAt === undefined) { + return false + } + if (Date.now() - closedAt >= CLOSED_PANE_BINDING_FENCE_MS) { + closedAtByPaneKey.delete(paneKey) + return false + } + return true +} + +export function _resetClosedPaneFencesForTests(): void { + closedAtByPaneKey.clear() +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index a706d8d50a10..ecb32f345644 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -34,6 +34,11 @@ import { writeRestorable } from './restorable-write' import { assignWorkspaceSessionPartition } from './terminal-topology-membership' import { keepWslMoveFrozenSessionsForStore } from '../moving-wsl-projects/wsl-move-session-freeze' import { resolveWindowPaneDetach, type WindowPaneDetachCommand } from './window-pane-detach' +import { + fencedIfLayoutOwner, + layoutCommandFence, + partitionsFence +} from './closed-pane-binding-fence' // The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Wraps the close and // the pane move; the close transform still lives in runtime/ and other writers move here later. @@ -52,7 +57,11 @@ export function closeLeafOrTab( ): () => DurableProfileStateMutation { return traced( commit.target.kind === 'pane' ? 'close_leaf' : 'close_tab', - terminalSurfaceCloseMutation(commit), + // A start under way in a pane main closed must not bring it back. + fencedIfLayoutOwner( + () => ({ sessions: commit, fences: true }), + terminalSurfaceCloseMutation(commit) + ), // Refusals are fixed reason codes, never ids. (refusal) => refusal?.message ) @@ -81,12 +90,16 @@ export function admitPane( ): () => DurableProfileStateMutation { return traced( 'admit_pane', - () => - commitPaneChange( - hostId, - admitPaneToSession(hostId, context.getSession(hostId), admission), - context - ), + // A pane main writes again under a closed pane's id binds as any. + fencedIfLayoutOwner( + () => partitionsFence([hostId], context.getSession, false), + () => + commitPaneChange( + hostId, + admitPaneToSession(hostId, context.getSession(hostId), admission), + context + ) + ), (outcome) => (outcome === 'admitted' || outcome === 'exists' ? undefined : outcome) ) } @@ -99,12 +112,16 @@ export function withdrawPane( ): () => DurableProfileStateMutation { return traced( 'withdraw_pane', - () => - commitPaneChange( - hostId, - withdrawPaneFromSession(hostId, context.getSession(hostId), pane), - context - ), + // A start under way in a pane main took back must not bring it back. + fencedIfLayoutOwner( + () => partitionsFence([hostId], context.getSession, true), + () => + commitPaneChange( + hostId, + withdrawPaneFromSession(hostId, context.getSession(hostId), pane), + context + ) + ), (code) => code ?? undefined ) } @@ -120,14 +137,18 @@ export function applyLayout( ): () => DurableProfileStateMutation { return traced( 'apply_layout', - () => - command.type === 'movePaneToNewTab' && command.windowDetach - ? commitWindowPaneDetach( - hostIds, - { ...command, windowDetach: command.windowDetach }, - context - ) - : commitLayoutCommand(hostIds, command, context), + // Closes fence the panes they removed; creates lift the fence on the ids they write. + fencedIfLayoutOwner( + () => layoutCommandFence(command.type, hostIds, context.getSession), + () => + command.type === 'movePaneToNewTab' && command.windowDetach + ? commitWindowPaneDetach( + hostIds, + { ...command, windowDetach: command.windowDetach }, + context + ) + : commitLayoutCommand(hostIds, command, context) + ), (outcome) => (outcome.ok ? undefined : outcome.code) ) } diff --git a/src/main/persistence/terminal-topology/unstarted-pane-withdraw.ts b/src/main/persistence/terminal-topology/unstarted-pane-withdraw.ts new file mode 100644 index 000000000000..60cc61707b73 --- /dev/null +++ b/src/main/persistence/terminal-topology/unstarted-pane-withdraw.ts @@ -0,0 +1,30 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { + CommandOf, + LayoutRefusalCode +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' +import { withdrawPane, type TerminalTopologyCommitContext } from './terminal-topology-commit' + +/** Settled with bindings in the write queue: what keeps a pane whose terminal never started. */ +export type UnstartedPaneKept = 'bound' | 'starting' + +/** + * `withdrawPane` for a pane with no terminal: one that bound since ('bound') or whose start is under + * way ('starting') stays, and nothing is written. + */ +export function withdrawUnstartedPane( + hostId: ExecutionHostId, + pane: CommandOf<'closePane'>, + context: TerminalTopologyCommitContext, + stillStarting: () => boolean +): () => DurableProfileStateMutation { + const withdraw = withdrawPane(hostId, pane, context) + return () => { + const layout = context.getSession(hostId).terminalLayoutsByTabId?.[pane.tabId] + if (layout?.ptyIdsByLeafId?.[pane.leafId]) { + return { value: 'bound', persist: false } + } + return stillStarting() ? { value: 'starting', persist: false } : withdraw() + } +} diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index 605c258f4f7a..106e956bcbe0 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -13,6 +13,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' +import { findProjectHostSetup } from '../../../shared/project-host-setup-target' import { makeProjectHostSetupId } from './project-host-compatibility' import { repoGitUsernameCacheKey } from './repo-hydration' @@ -147,7 +148,7 @@ export class ProjectHostPersistenceOperations { } updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + const setup = findProjectHostSetup(this.state.projectHostSetups, args) if (!setup) { return null } @@ -155,9 +156,11 @@ export class ProjectHostPersistenceOperations { if (!project) { return null } - const repo = setup.repoId - ? this.state.repos.find((entry) => entry.id === setup.repoId) - : undefined + const repo = this.findSetupRepo(setup) + if (setup.repoId && !repo) { + // A stale repo-backed setup; editing it would write a sibling host's row. + return null + } if (repo) { const updated = this.updateRepoBackedProjectHostSetup(setup, repo, args.updates) const updatedProject = updated @@ -171,8 +174,17 @@ export class ProjectHostPersistenceOperations { return { project, setup: updatedSetup } } + // Why: the same repo id can exist on several hosts; never fall back to a sibling host's row. + private findSetupRepo(setup: ProjectHostSetup): Repo | undefined { + return setup.repoId + ? this.state.repos.find( + (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId + ) + : undefined + } + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - const setup = this.state.projectHostSetups.find((entry) => entry.id === args.setupId) + const setup = findProjectHostSetup(this.state.projectHostSetups, args) if (!setup) { return null } @@ -180,21 +192,13 @@ export class ProjectHostPersistenceOperations { if (!project) { return null } - // Why: the same repo id can exist on multiple execution hosts, so match this setup's own host - // row and never fall back to a sibling host's row โ€” a stale repoId/hostId would delete that host's - // registration. With no exact match the setup is stale, and the path below drops just the setup. - const repo = setup.repoId - ? this.state.repos.find( - (entry) => entry.id === setup.repoId && getRepoExecutionHostId(entry) === setup.hostId - ) - : undefined + // With no exact row the setup is stale, and the path below drops just the setup. + const repo = this.findSetupRepo(setup) if (repo) { this.removeProjectForHost(repo.id, setup.hostId) return { project, setup, repo: this.hydrateRepo(repo) } } - this.state.projectHostSetups = this.state.projectHostSetups.filter( - (entry) => entry.id !== setup.id - ) + this.state.projectHostSetups = this.state.projectHostSetups.filter((entry) => entry !== setup) this.scheduleSave() return { project, setup } } diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 05d291bd1710..33c08795aefd 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -56,12 +56,15 @@ export class ProjectHostSetupPersistenceOperations { repoUpdates.projectHostSetupMethod = updates.setupMethod } const updatedRepo = - Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo.id, repoUpdates) : repo + Object.keys(repoUpdates).length > 0 ? this.updateRepo(repo, repoUpdates) : repo if (!updatedRepo) { return null } return { - setup: this.state.projectHostSetups.find((entry) => entry.id === setup.id) ?? setup, + setup: + this.state.projectHostSetups.find( + (entry) => entry.id === setup.id && entry.hostId === setup.hostId + ) ?? setup, repo: updatedRepo } } diff --git a/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts b/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts index a83f0cbdf76e..1452ad1a8810 100644 --- a/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts +++ b/src/main/persistence/tracking-repos/repo-update-host-guard.test.ts @@ -42,7 +42,10 @@ describe('updateRepo host guard', () => { makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) ]) - const result = operations.updateRepo('repo-1', { displayName: 'Renamed' }, 'local') + const result = operations.updateRepo( + { id: 'repo-1', executionHostId: 'local' }, + { displayName: 'Renamed' } + ) expect(result).toBeNull() expect(scheduleSave).not.toHaveBeenCalled() @@ -57,7 +60,12 @@ describe('updateRepo host guard', () => { makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) ]) - expect(operations.updateRepo('repo-missing', { displayName: 'Renamed' }, 'local')).toBeNull() + expect( + operations.updateRepo( + { id: 'repo-missing', executionHostId: 'local' }, + { displayName: 'Renamed' } + ) + ).toBeNull() expect(errors).toEqual([]) }) @@ -66,7 +74,10 @@ describe('updateRepo host guard', () => { makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) ]) - const result = operations.updateRepo('repo-1', { displayName: 'Renamed' }, 'runtime:env-1') + const result = operations.updateRepo( + { id: 'repo-1', executionHostId: 'runtime:env-1' }, + { displayName: 'Renamed' } + ) expect(result?.displayName).toBe('Renamed') expect(state.repos[0].displayName).toBe('Renamed') @@ -79,20 +90,34 @@ describe('updateRepo host guard', () => { const peer = makeRepo({ id: 'repo-1', displayName: 'Peer', executionHostId: 'ssh:peer' }) const { operations, state } = makeOperations([local, peer]) - const result = operations.updateRepo('repo-1', { displayName: 'Local edit' }, 'local') + const result = operations.updateRepo( + { id: 'repo-1', executionHostId: 'local' }, + { displayName: 'Local edit' } + ) expect(result?.displayName).toBe('Local edit') // The peer's row is untouched: a client-local write must never repair another host's metadata. expect(state.repos[1].displayName).toBe('Peer') }) - it('writes without a host argument exactly as before', () => { - const { operations, state } = makeOperations([ - makeRepo({ id: 'repo-1', executionHostId: 'runtime:env-1' }) - ]) + it('reads a target without host fields as the local row, never the first same-id row', () => { + const peer = makeRepo({ id: 'repo-1', displayName: 'Peer', connectionId: 'peer' }) + const local = makeRepo({ id: 'repo-1', displayName: 'Local' }) + const { operations, state } = makeOperations([peer, local]) - expect(operations.updateRepo('repo-1', { displayName: 'Renamed' })?.displayName).toBe('Renamed') - expect(state.repos[0].displayName).toBe('Renamed') + operations.updateRepo({ id: 'repo-1' }, { displayName: 'Renamed' }) + + expect(state.repos.map((repo) => repo.displayName)).toEqual(['Peer', 'Renamed']) + }) + + it('addresses an SSH row through its connectionId alone', () => { + const local = makeRepo({ id: 'repo-1', displayName: 'Local' }) + const peer = makeRepo({ id: 'repo-1', displayName: 'Peer', connectionId: 'peer' }) + const { operations, state } = makeOperations([local, peer]) + + operations.updateRepo(peer, { displayName: 'Peer edit' }) + + expect(state.repos.map((repo) => repo.displayName)).toEqual(['Local', 'Peer edit']) expect(errors).toEqual([]) }) }) diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 8cffb741c0cd..25d4eff9d707 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -1,6 +1,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { RepoHostTarget } from '../../../shared/repo-host-identity' import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' @@ -30,11 +31,8 @@ export type RepoUpdateMutationOperations = { export function findRepoRowForHostScopedWrite( repos: readonly Repo[], id: string, - hostId: ExecutionHostId | undefined + hostId: ExecutionHostId ): Repo | undefined { - if (!hostId) { - return repos.find((candidate) => candidate.id === id) - } const matched = repos.find( (candidate) => candidate.id === id && getRepoExecutionHostId(candidate) === hostId ) @@ -97,7 +95,7 @@ export class RepoUpdatePersistenceOperations { } updateRepo( - id: string, + target: RepoHostTarget, updates: Partial< Pick< Repo, @@ -131,10 +129,13 @@ export class RepoUpdatePersistenceOperations { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null ghAccount?: GhAccountBinding | null - }, - hostId?: ExecutionHostId + } ): Repo | null { - const repo = findRepoRowForHostScopedWrite(this.state.repos, id, hostId) + const repo = findRepoRowForHostScopedWrite( + this.state.repos, + target.id, + getRepoExecutionHostId(target) + ) if (!repo) { return null } @@ -260,7 +261,7 @@ export class RepoUpdatePersistenceOperations { } } Object.assign(repo, sanitizedUpdates) - this.bumpLocalWorktreeScanGeneration(id) + this.bumpLocalWorktreeScanGeneration(repo.id) this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return this.hydrateRepo(repo) diff --git a/src/main/persistence/window-save-divergence-values.ts b/src/main/persistence/window-save-divergence-values.ts new file mode 100644 index 000000000000..00467df2d6ca --- /dev/null +++ b/src/main/persistence/window-save-divergence-values.ts @@ -0,0 +1,109 @@ +// The save-divergence log's before (main's) and after (the save's) values of each changed field, +// bounded and stripped of content: ids (an editor tab's is its file path, as `paths` already +// shows), numbers and flags as they are, any other text only as its length, since titles, labels +// and records can hold what a user typed or an agent wrote. + +/** Keys whose text is an id or a fixed name, read only inside the layout's own records; an array's items take its key. */ +const ID_KEYS = new Set([ + 'id', + 'entityId', + 'groupId', + 'leafId', + 'chatLeafId', + 'ptyId', + 'incarnationId', + 'tabOrder', + 'pageIds', + 'sessionProfileId', + 'type', + 'kind', + 'direction', + 'viewMode', + 'agentSessionAgent', + 'launchAgent', + 'color' +]) +/** + * Records and fields that embed other subsystems' data (an agent's resume record carries its prompt, + * launch env and transcript path; editor views carry git snapshots): only their size is logged. + */ +const OPAQUE_RECORDS = new Set(['file', 'editor view']) +const OPAQUE_FIELDS = new Set(['sleeping', 'value']) +const MAX_SHOWN_ID_LENGTH = 80 +/** A daemon terminal id embeds its worktree id, which may be a long path. */ +const MAX_SHOWN_PTY_ID_LENGTH = 256 +const MAX_ITEMS = 8 +const MAX_DEPTH = 6 +/** Changed fields one report line carries values for. */ +const MAX_CHANGES = 8 +/** Values each side of one change may carry, counted per array, object and scalar. */ +const SIDE_BUDGET = 32 + +/** One changed field of one record (` `), as main holds it and as the save writes it. */ +export type FieldChange = { record: string; field: string; before: unknown; after: unknown } + +type Budget = { left: number } + +function compact( + value: unknown, + key: string | undefined, + budget: Budget, + depth: number, + opaque: boolean +): unknown { + if (budget.left <= 0) { + return 'โ€ฆ' + } + budget.left-- + if (value === null || value === undefined) { + return null + } + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const limit = key === 'ptyId' ? MAX_SHOWN_PTY_ID_LENGTH : MAX_SHOWN_ID_LENGTH + return !opaque && key && ID_KEYS.has(key) && value.length <= limit + ? value + : `` + } + if (typeof value !== 'object') { + return `<${typeof value}>` + } + if (Array.isArray(value)) { + if (opaque || depth >= MAX_DEPTH) { + return `<${value.length} items>` + } + const shown = value + .slice(0, MAX_ITEMS) + .map((item) => compact(item, key, budget, depth + 1, false)) + return value.length > MAX_ITEMS ? [...shown, `+${value.length - MAX_ITEMS} more`] : shown + } + const entries = Object.entries(value).filter(([, item]) => item !== undefined) + if (opaque || depth >= MAX_DEPTH) { + return `<${entries.length} keys>` + } + const shown = Object.fromEntries( + entries + .slice(0, MAX_ITEMS) + .map(([name, item]) => [name, compact(item, name, budget, depth + 1, false)]) + ) + return entries.length > MAX_ITEMS + ? { ...shown, '+': `${entries.length - MAX_ITEMS} more keys` } + : shown +} + +/** `changes` (with the kind of record each names) compacted for one report line. */ +export function compactFieldChanges( + changes: readonly (FieldChange & { kind: string })[] +): FieldChange[] { + return changes.slice(0, MAX_CHANGES).map(({ kind, record, field, before, after }) => { + const opaque = OPAQUE_RECORDS.has(kind) || OPAQUE_FIELDS.has(field) + return { + record, + field, + before: compact(before, field, { left: SIDE_BUDGET }, 0, opaque), + after: compact(after, field, { left: SIDE_BUDGET }, 0, opaque) + } + }) +} diff --git a/src/main/persistence/window-save-divergence.test.ts b/src/main/persistence/window-save-divergence.test.ts index 9dc7255f7a3b..d0fd922b45f0 100644 --- a/src/main/persistence/window-save-divergence.test.ts +++ b/src/main/persistence/window-save-divergence.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { addWorkspace, @@ -249,6 +250,221 @@ describe('reportWindowSaveDrift', () => { ) }) + it('logs mainโ€™s and the saveโ€™s pane trees for a pane tree difference', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const stored = session([{ id: 'tab-a' }]) + const next = structuredClone(stored) + next.terminalLayoutsByTabId['tab-a'] = { + ...next.terminalLayoutsByTabId['tab-a']!, + root: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', leafId: leaf(1) }, + second: { type: 'leaf', leafId: leaf(2) } + } + } + + save(stored, next) + + const logged = JSON.parse(readFileSync(logPath, 'utf8').trim()) + expect(logged.paths).toEqual(['tab tab-a changed: panes']) + expect(logged.values).toEqual([ + { + record: 'tab tab-a', + field: 'panes', + before: { root: { type: 'leaf', leafId: leaf(1) } }, + after: { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.5, + first: { type: 'leaf', leafId: leaf(1) }, + second: { type: 'leaf', leafId: leaf(2) } + } + } + }, + { + record: 'tab tab-a', + field: 'ptys', + before: [{ leafId: leaf(1), ptyId: null }], + after: [ + { leafId: leaf(1), ptyId: null }, + { leafId: leaf(2), ptyId: null } + ] + } + ]) + }) + + it('logs the terminal each pane is bound to on both sides of a pane change', () => { + const logPath = join(logDir!, 'divergence.jsonl') + // A daemon terminal id carries its worktree id, often longer than other ids. + const mainPty = `${GIT_KEY}/${'nested/'.repeat(10)}@@a1b2c3d4` + const stored = session([{ id: 'tab-a' }]) + stored.terminalLayoutsByTabId['tab-a'] = { + ...stored.terminalLayoutsByTabId['tab-a']!, + ptyIdsByLeafId: { [leaf(1)]: mainPty } + } + const next = structuredClone(stored) + next.terminalLayoutsByTabId['tab-a'] = { + ...next.terminalLayoutsByTabId['tab-a']!, + chatLeafId: leaf(1), + ptyIdsByLeafId: { [leaf(1)]: 'pty-window' } + } + + save(stored, next) + + const logged = JSON.parse(readFileSync(logPath, 'utf8').trim()) + expect(logged.paths).toEqual(['tab tab-a changed: panes']) + expect(logged.values).toContainEqual({ + record: 'tab tab-a', + field: 'ptys', + before: [{ leafId: leaf(1), ptyId: mainPty }], + after: [{ leafId: leaf(1), ptyId: 'pty-window' }] + }) + }) + + it('logs a pane main and the save bind to different terminals within a changed tree', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const stored = session([{ id: 'tab-a' }]) + stored.terminalLayoutsByTabId['tab-a'] = { + ...stored.terminalLayoutsByTabId['tab-a']!, + ptyIdsByLeafId: { [leaf(1)]: 'pty-1' } + } + const next = structuredClone(stored) + next.terminalLayoutsByTabId['tab-a'] = { + ...next.terminalLayoutsByTabId['tab-a']!, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: leaf(1) }, + second: { type: 'leaf', leafId: leaf(2) } + }, + ptyIdsByLeafId: { [leaf(1)]: 'pty-2', [leaf(2)]: 'p'.repeat(300) } + } + + save(stored, next) + + const logged = JSON.parse(readFileSync(logPath, 'utf8').trim()) + expect(logged.values).toContainEqual({ + record: 'tab tab-a', + field: 'ptys', + before: [{ leafId: leaf(1), ptyId: 'pty-1' }], + after: [ + { leafId: leaf(1), ptyId: 'pty-2' }, + { leafId: leaf(2), ptyId: '' } + ] + }) + }) + + it('logs a changed titleโ€™s length, never its text', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const secret = 'cat ~/.ssh/id_ed25519' + + save(session([{ id: 'tab-a' }]), session([{ id: 'tab-a', title: secret }])) + + const text = readFileSync(logPath, 'utf8') + expect(text).not.toContain(secret) + expect(JSON.parse(text.trim()).values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + record: 'tab tab-a', + field: 'terminal', + after: { defaultTitle: `` } + }) + ]) + ) + }) + + it('logs only the size of a paneโ€™s resume record, which holds its prompt and launch env', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const stored = session([{ id: 'tab-a' }]) + const next = structuredClone(stored) + next.terminalLayoutsByTabId['tab-a'] = { + ...next.terminalLayoutsByTabId['tab-a']!, + titlesByLeafId: { [leaf(1)]: 'build' } + } + const record: SleepingAgentSessionRecord = { + paneKey: `tab-a:${leaf(1)}`, + worktreeId: GIT_KEY, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-secret-id' }, + prompt: 'fix the login bug', + state: 'done', + capturedAt: 1, + updatedAt: 1, + launchConfig: { agentArgs: '', agentEnv: { type: 'env-secret-value' } } + } + stored.sleepingAgentSessionsByPaneKey = { [`tab-a:${leaf(1)}`]: record } + next.sleepingAgentSessionsByPaneKey = { + [`tab-a:${leaf(1)}`]: { ...record, prompt: 'a different prompt' } + } + + save(stored, next) + + const text = readFileSync(logPath, 'utf8') + for (const secret of [ + 'login bug', + 'different prompt', + 'session-secret', + 'env-secret', + 'build' + ]) { + expect(text).not.toContain(secret) + } + expect(JSON.parse(text.trim()).values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ record: `leaf ${leaf(1)}`, field: 'title', after: '' }) + ]) + ) + }) + + it('logs both sides of each change when many panes change at once', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const tabs = Array.from({ length: 4 }, (_, index) => ({ id: `tab-${index}` })) + const stored = session(tabs) + const next = structuredClone(stored) + const split = (id: number) => ({ + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: leaf(id) }, + second: { type: 'leaf' as const, leafId: `${id}-b` } + }) + for (const [index, tab] of tabs.entries()) { + next.terminalLayoutsByTabId[tab.id] = { + ...next.terminalLayoutsByTabId[tab.id]!, + root: { type: 'split', direction: 'horizontal', first: split(index + 1), second: split(9) } + } + } + + save(stored, next) + + const { values } = JSON.parse(readFileSync(logPath, 'utf8').trim()) + expect(values).toHaveLength(8) + expect(JSON.stringify(values)).not.toContain('โ€ฆ') + }) + + it('cuts a long tab order down to its first ids and a count', () => { + const logPath = join(logDir!, 'divergence.jsonl') + const tabs = Array.from({ length: 30 }, (_, index) => ({ id: `tab-${index}` })) + + save(session(tabs), session(tabs.toReversed())) + + const logged = JSON.parse(readFileSync(logPath, 'utf8').trim()) + const order = logged.values.find((value: { field: string }) => value.field === 'tabOrder') + expect(order.before).toEqual([ + 'tab-0', + 'tab-1', + 'tab-2', + 'tab-3', + 'tab-4', + 'tab-5', + 'tab-6', + 'tab-7', + '+22 more' + ]) + }) + it('leaves a paired serverโ€™s partition alone: its layout is the serverโ€™s', () => { const next = session([{ id: 'tab-c' }]) expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next) diff --git a/src/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index b16c0a9880b8..5835293ed740 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -21,8 +21,10 @@ import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model' import type { TabGroupLayoutNode } from '../../shared/tab-types' +import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { droppedTabBarEntries, terminalTabBarEntries } from './window-save-raw-session-divergence' +import { compactFieldChanges, type FieldChange } from './window-save-divergence-values' /** Set by evidence runs: each divergence is appended there as one JSON line. */ const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG' @@ -30,7 +32,11 @@ const MAX_PATHS = 12 /** A pane's terminal binding and resume record: main writes them on every spawn and capture. */ const BINDING_FIELDS = new Set(['ptyId', 'incarnationId', 'sleeping']) -export type WindowSaveDivergence = { workspace: string; paths: string[] } +/** `values`: main's and the save's values of each changed field, content left out. */ +export type WindowSaveDivergence = { workspace: string; paths: string[]; values?: FieldChange[] } + +/** One report path, with the field values behind a `changed` one. */ +type Difference = { path: string; changes?: (FieldChange & { kind: string })[] } /** Main's layout model of one partition, as the session holds it. */ export function layoutModelOf( @@ -54,25 +60,47 @@ function changedFields(before: unknown, after: unknown): string[] { ) } +function fieldOf(item: unknown, field: string): unknown { + return field === 'value' || typeof item !== 'object' || !item + ? item + : Object.entries(item).find(([key]) => key === field)?.[1] +} + +/** Values logged beside a changed record's own fields, by its id and changed fields. */ +type Alongside = (id: string, fields: string[]) => Omit[] + function diffKeyed( prefix: string, before: Map, - after: Map -): string[] { - const paths: string[] = [] + after: Map, + alongside?: Alongside +): Difference[] { + const paths: Difference[] = [] for (const [id, item] of after) { if (!before.has(id)) { - paths.push(`${prefix} ${id} added`) + paths.push({ path: `${prefix} ${id} added` }) continue } - const fields = changedFields(before.get(id), item) + const was = before.get(id) + const fields = changedFields(was, item) if (fields.length > 0) { - paths.push(`${prefix} ${id} changed: ${fields.join(', ')}`) + const record = `${prefix} ${id}` + paths.push({ + path: `${record} changed: ${fields.join(', ')}`, + changes: [ + ...fields.map((field) => ({ + field, + before: fieldOf(was, field), + after: fieldOf(item, field) + })), + ...(alongside?.(id, fields) ?? []) + ].map((change) => ({ kind: prefix, record, ...change })) + }) } } for (const id of before.keys()) { if (!after.has(id)) { - paths.push(`${prefix} ${id} removed`) + paths.push({ path: `${prefix} ${id} removed` }) } } return paths @@ -82,6 +110,22 @@ function listById(items: readonly T[] | undefined, id: (item: T) => string) { return new Map((items ?? []).map((item) => [id(item), item])) } +/** A terminal tab's panes in tree order with the terminal each is bound to, null while unbound. */ +function paneBindings(workspace: WorkspaceLayout, tabId: string) { + const tab = workspace.tabs.find((candidate) => candidate.id === tabId) + const leafIds: string[] = [] + const visit = (node: TerminalPaneLayoutNode | null): void => { + if (node?.type === 'leaf') { + leafIds.push(node.leafId) + } else if (node) { + visit(node.first) + visit(node.second) + } + } + visit(tab?.kind === 'terminal' ? tab.panes.root : null) + return leafIds.map((leafId) => ({ leafId, ptyId: workspace.leaves?.[leafId]?.ptyId ?? null })) +} + function recordById(items: Readonly> | undefined) { return new Map(Object.entries(items ?? {})) } @@ -145,14 +189,14 @@ function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null { function diffWorkspaceLayouts( before: WorkspaceLayout | undefined, after: WorkspaceLayout | undefined -): string[] { +): Difference[] { if (!hasContent(before) || !hasContent(after)) { return hasContent(before) || hasContent(after) - ? [hasContent(after) ? 'workspace added' : 'workspace removed'] + ? [{ path: hasContent(after) ? 'workspace added' : 'workspace removed' }] : [] } const leaves = unboundLeaves(before.leaves, after.leaves) - const paths = [ + const paths: Difference[] = [ ...diffKeyed( 'group', listById(before.groups, (g) => g.id), @@ -161,7 +205,12 @@ function diffWorkspaceLayouts( ...diffKeyed( 'tab', listById(before.tabs, (t) => t.id), - listById(after.tabs, (t) => t.id) + listById(after.tabs, (t) => t.id), + // Bindings are main's to write, so they never make a path; they show who holds a changed pane. + (id, fields) => + fields.includes('panes') + ? [{ field: 'ptys', before: paneBindings(before, id), after: paneBindings(after, id) }] + : [] ), ...diffKeyed( 'file', @@ -176,8 +225,21 @@ function diffWorkspaceLayouts( ), ...diffKeyed('leaf', leaves.was, leaves.now) ] - if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) { - paths.push('group layout changed') + const wasTree = groupTreeOf(before) + const nowTree = groupTreeOf(after) + if (stableJson(wasTree) !== stableJson(nowTree)) { + paths.push({ + path: 'group layout changed', + changes: [ + { + kind: 'group layout', + record: 'group layout', + field: 'tree', + before: wasTree, + after: nowTree + } + ] + }) } return paths } @@ -199,12 +261,17 @@ export function windowSaveDivergences( // Ignored, as main's own writes: pane bindings, resume records, default-tab marks, closed-tab records, row owners, // clientHostedBrowserPagesByWorkspace and the topology revision. for (const workspace of workspaces) { - const paths = [ + const differences: Difference[] = [ ...diffWorkspaceLayouts(was?.workspaces[workspace], now?.workspaces[workspace]), - ...(rawPaths.get(workspace) ?? []) - ] - if (paths.length > 0) { - result.push({ workspace, paths: paths.slice(0, MAX_PATHS) }) + ...(rawPaths.get(workspace) ?? []).map((path) => ({ path })) + ].slice(0, MAX_PATHS) + if (differences.length > 0) { + const changes = differences.flatMap((difference) => difference.changes ?? []) + result.push({ + workspace, + paths: differences.map((difference) => difference.path), + ...(changes.length > 0 ? { values: compactFieldChanges(changes) } : {}) + }) } } return result diff --git a/src/main/pi/agent-status-extension-async-subagents.test.ts b/src/main/pi/agent-status-extension-async-subagents.test.ts index 5eb9196163ca..da3fc0646ef3 100644 --- a/src/main/pi/agent-status-extension-async-subagents.test.ts +++ b/src/main/pi/agent-status-extension-async-subagents.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' +import { + HOOK_REQUEST_MAX_BYTES, + parseAgentHookJson +} from '../../shared/agent-hook-listener/request-body' +import { normalizeAgentSubagentsField } from '../../shared/agent-status-subagent-snapshot' import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' import { agentEndCount, @@ -309,18 +313,49 @@ describe('Pi child rows', () => { expect(childIds(posts(harness)[1])).toEqual(['run-b']) }) - // Why: the generated cap is interpolated from the host's, so a drift would show up here - // as an over-long roster the host would silently truncate on arrival. - it('caps the posted roster at the host limit', async () => { + it('posts every running child', async () => { const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) await harness.callHook('agent_start') - for (let index = 0; index < AGENT_STATUS_MAX_SUBAGENTS + 4; index++) { + for (let index = 0; index < 40; index++) { startAsync(harness, `run-${index}`, 'scout') } await vi.advanceTimersByTimeAsync(0) - expect(childIds(posts(harness).at(-1))).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) - expect(childIds(posts(harness).at(-1))?.at(-1)).toBe(`run-${AGENT_STATUS_MAX_SUBAGENTS - 1}`) + expect(childIds(posts(harness).at(-1))).toHaveLength(40) + }) + + it.each(['pi', 'omp'] as const)('fits %s children using the host previews', async (kind) => { + const harness = createAgentStatusExtensionHarness({ kind }) + const children = [ + { + id: 'run-a', + agentType: `\u2003${'a'.repeat(39)}๐Ÿ™‚${'a'.repeat(100)}`, + description: `\ufeff \t${'d'.repeat(159)}๐Ÿ™‚${'d'.repeat(300_000)}` + }, + { + id: 'run-b', + agentType: ` \tReview\r\n\u2028 module\t\t${'a'.repeat(100)}`, + description: ` \tMap\r\n\u2029 module\t\t${'d'.repeat(300_000)}` + } + ] + await harness.callHook('agent_start') + for (const child of children) { + harness.emitPiEvent('task:subagent:lifecycle', { + ...child, + agent: child.agentType, + status: 'started' + }) + } + await vi.advanceTimersByTimeAsync(0) + + const body = String(harness.fetchMock.mock.calls.at(-1)?.[1]?.body) + expect(Buffer.byteLength(body)).toBeLessThanOrEqual(HOOK_REQUEST_MAX_BYTES) + expect(() => parseAgentHookJson(body)).not.toThrow() + expect(normalizeAgentSubagentsField(posts(harness).at(-1)?.subagents)).toEqual( + normalizeAgentSubagentsField( + children.map((child) => ({ ...child, state: 'working', startedAt: 0 })) + )?.map((child) => ({ ...child, startedAt: expect.any(Number) })) + ) }) it('posts nothing for the end of a run it is not tracking', async () => { diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 5a3d23f47a7a..78173b1a15a9 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -124,8 +124,8 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin // Why: Pi resumes from an existing transcript; OMP resumes directly by session id (#8962). const payloadLine = kind !== 'omp' - ? ' payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata(metadata)), ...(final ? {} : subagentPayload()), ...extra },' - : ' payload: { hook_event_name: hookEventName, ...metadata, ...(final ? {} : subagentPayload()), ...extra },' + ? ' payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata(metadata)), ...subagents, ...extra },' + : ' payload: { hook_event_name: hookEventName, ...metadata, ...subagents, ...extra },' // Why: keep this string self-contained โ€” it runs inside the pi process, // so it cannot import from Orca's main bundle. fs/http coords come from @@ -246,7 +246,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const paneKey = process.env.ORCA_PANE_KEY', ' if (!coords.port || !coords.token || !paneKey) return', ' const url = `http://127.0.0.1:${coords.port}${resolveHookPath(ompRuntime)}`', - ' const body = JSON.stringify({', + ' const bodyWith = (subagents: Record): string => JSON.stringify({', ' paneKey,', " launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || '',", " tabId: process.env.ORCA_TAB_ID || '',", @@ -255,6 +255,8 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' version: coords.version,', payloadLine, ' })', + ' const bareBody = bodyWith({})', + ' const body = final ? bareBody : bodyWith(subagentPayload(HOOK_BODY_BUDGET_BYTES - Buffer.byteLength(bareBody)))', " const controller = typeof AbortController !== 'undefined' ? new AbortController() : null", ' let timeout: ReturnType | undefined', ' const timeoutPromise = new Promise((_resolve, reject) => {', diff --git a/src/main/pi/agent-status-subagent-roster-source.ts b/src/main/pi/agent-status-subagent-roster-source.ts index a78cceec8745..f462def4b765 100644 --- a/src/main/pi/agent-status-subagent-roster-source.ts +++ b/src/main/pi/agent-status-subagent-roster-source.ts @@ -2,7 +2,11 @@ // generated extension holds the pane's completion until every child it saw start is gone. import type { PiAgentKind } from '../../shared/pi-agent-kind' -import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' +import { HOOK_REQUEST_MAX_BYTES } from '../../shared/agent-hook-listener/request-body' +import { + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, + AGENT_TYPE_MAX_LENGTH +} from '../../shared/agent-status-field-normalization' // Module scope: post() reads the roster when a body is built, so a coalesced or // retried post always carries the children live at delivery. @@ -10,8 +14,10 @@ export function getPiSubagentSnapshotSourceLines(): string[] { return [ 'type SubagentDetail = { agentType?: string; description?: string; startedAt: number; workflow?: boolean; parent?: string; registration?: object }', 'type SubagentRoster = { active: Set; exited?: Set; details?: Map; waiting: boolean; ownsPane?: boolean; runGeneration?: number; endedRunGeneration?: number; completionPostedGeneration?: number; parked?: Map>; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void; runnerExitCheck?: ReturnType | null; onRunnerExitSettled?: () => void }', - // Why: interpolated, not re-typed, so the extension cap cannot drift from the host's. - `const MAX_SUBAGENT_SNAPSHOT = ${AGENT_STATUS_MAX_SUBAGENTS}`, + // Why: the host refuses an oversized body whole. Half its limit also keeps the roster under + // the host's JSON structure-token limit; interpolated so it cannot drift from the host's. + `const HOOK_BODY_BUDGET_BYTES = ${Math.floor(HOOK_REQUEST_MAX_BYTES / 2)}`, + 'const SUBAGENTS_FIELD_BYTES = Buffer.byteLength(\',"subagents":[]\')', 'let subagentRoster: SubagentRoster | null = null', '', // Why: an exited runner is already gone, and a pi-subagents workflow run is the lead @@ -20,14 +26,31 @@ export function getPiSubagentSnapshotSourceLines(): string[] { ' return roster.active.has(id) && !roster.exited?.has(id) && roster.details?.get(id)?.workflow !== true', '}', '', - 'function subagentPayload(): Record {', + // Match the host's bounded single-line previews before reserving body bytes. + 'function subagentPreview(value: string | undefined, maxLength: number): string | undefined {', + " if (typeof value !== 'string') return undefined", + " let preview = value.slice(0, maxLength * 8 + 64).trimStart().replace(/[\\r\\n\\u2028\\u2029]+/g, ' ').slice(0, maxLength)", + ' if (preview.length < maxLength) preview = preview.trimEnd()', + ' else if (/[\\ud800-\\udbff]$/.test(preview)) preview = preview.slice(0, -1)', + ' return preview || undefined', + '}', + '', + // Only the posted rows are fitted to the body; the active set keeps every child. + 'function subagentPayload(byteBudget: number): Record {', ' if (!subagentRoster) return {}', ' const subagents: Record[] = []', + ' let bytes = SUBAGENTS_FIELD_BYTES', ' for (const id of subagentRoster.active) {', ' if (!isVisibleSubagent(subagentRoster, id)) continue', ' const detail = subagentRoster.details?.get(id)', - " subagents.push({ id, state: 'working', startedAt: detail?.startedAt ?? 0, ...(detail?.agentType ? { agentType: detail.agentType } : {}), ...(detail?.description ? { description: detail.description } : {}) })", - ' if (subagents.length >= MAX_SUBAGENT_SNAPSHOT) break', + ` const agentType = subagentPreview(detail?.agentType, ${AGENT_TYPE_MAX_LENGTH})`, + ` const description = subagentPreview(detail?.description, ${AGENT_STATUS_TOOL_INPUT_MAX_LENGTH})`, + " const subagent = { id, state: 'working', startedAt: detail?.startedAt ?? 0, ...(agentType ? { agentType } : {}), ...(description ? { description } : {}) }", + ' const rowBytes = Buffer.byteLength(JSON.stringify(subagent)) + (subagents.length > 0 ? 1 : 0)', + // A row that doesn't fit is skipped, not a stop: a later, shorter row may still fit. + ' if (bytes + rowBytes > byteBudget) continue', + ' bytes += rowBytes', + ' subagents.push(subagent)', ' }', ' return subagents.length > 0 ? { subagents } : {}', '}', diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index 6ece66fa81cd..78ca0b3d79a8 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -41,7 +41,7 @@ const PI_RPC_RELEASE_LINES = [ { major: 1, floor: '1.0.0' } ] as const -function supportsPiRpcVersion(version: string): boolean { +export function supportsPiRpcVersion(version: string): boolean { return PI_RPC_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)) } diff --git a/src/main/pi/rpc-runtime-metadata.ts b/src/main/pi/rpc-runtime-metadata.ts index 25561fe01f5b..a157752f6ac5 100644 --- a/src/main/pi/rpc-runtime-metadata.ts +++ b/src/main/pi/rpc-runtime-metadata.ts @@ -4,7 +4,7 @@ import { agentSessionAccountHome } from '../../shared/agent-session-account-home import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { StructuredAgentRuntimeMetadata } from '../runtime/structured-agent-runtime-registration-types' import { PI_RPC_AGENT } from './rpc-agent-definition' -import { piRpcVersionSupported, resolvePiRpcCommand } from './rpc-launch-resolution' +import { resolvePiRpcCommand, supportsPiRpcVersion } from './rpc-launch-resolution' import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' import { PI_SESSION_HISTORY } from './pi-session-history' import { PI_TRANSCRIPT_IMPORT } from './pi-transcript-import' @@ -32,7 +32,7 @@ export const PI_RPC_RUNTIME_METADATA: StructuredAgentRuntimeMetadata = { command: resolvePiRpcCommand(env, commandSettings), env }), - supportsVersion: (launch) => piRpcVersionSupported(launch) + supportsVersion: supportsPiRpcVersion }, resolveAccountHome: async ({ launchEnv }) => agentSessionAccountHome( diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts index 5b684b5f3d7e..d45c92e23ea2 100644 --- a/src/main/pi/rpc-runtime-registration.test.ts +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' +import { observeAgentCliVersion } from '../agent-cli-version-probe' import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' @@ -20,7 +21,12 @@ async function supportsLaunch({ cwd, env, commandSettings }: LaunchInput): Promi if (!check) { throw new Error('Pi registers a launch check') } - return check.supportsVersion({ ...check.resolveLaunch(env, commandSettings), cwd }) + const { command, env: launchEnv } = check.resolveLaunch(env, commandSettings) + const seen = await observeAgentCliVersion( + { program: command, cwd, env: launchEnv }, + check.supportsVersion + ) + return seen === 'supported' } let root: string let stockPi: string diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 139268fe0a78..11452053b28b 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -263,6 +263,9 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { releaseAcquisition(input: { sessionId: string }): Promise { return this.closeSession(input.sessionId) } + hasOwnedAcquisition(sessionId: string): boolean { + return this.sessions.has(sessionId) + } forceCloseSession(id: string): Promise { this.sessions.get(id)?.fail(new Error('Pi event sink failed')) return this.closeSession(id, false) diff --git a/src/main/plugins/plugin-artifact-pack-registry.ts b/src/main/plugins/plugin-artifact-pack-registry.ts new file mode 100644 index 000000000000..1b9e979f6190 --- /dev/null +++ b/src/main/plugins/plugin-artifact-pack-registry.ts @@ -0,0 +1,79 @@ +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const ARTIFACT_PACK_LOAD_CONCURRENCY = 4 + +type ArtifactPackLoadResult = + | { pluginKey: string; entries: Entry[] } + | { pluginKey: string; error: string } + +/** Loads one data contribution family from verified plugin trees. A plugin whose content fails + * verification or parsing contributes nothing and reports one error. */ +export class PluginArtifactPackRegistry { + private entries: Entry[] = [] + private readonly errors = new Map() + + constructor( + private readonly options: { + contentVerifier: PluginContentVerifier + contributions: (plugin: ValidDiscoveredPlugin) => readonly Contribution[] + load: (plugin: ValidDiscoveredPlugin, contribution: Contribution) => Promise + } + ) {} + + list(): readonly Entry[] { + return this.entries + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates: ValidDiscoveredPlugin[] = [] + for (const plugin of discovered) { + if ( + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + this.options.contributions(plugin).length > 0 + ) { + candidates.push(plugin) + } + } + const results = await mapWithConcurrency( + candidates, + ARTIFACT_PACK_LOAD_CONCURRENCY, + async (plugin): Promise> => { + try { + await this.options.contentVerifier.verify(plugin) + const entries = await Promise.all( + this.options + .contributions(plugin) + .map((contribution) => this.options.load(plugin, contribution)) + ) + return { pluginKey: plugin.pluginKey, entries } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.entries = results.flatMap((result) => ('entries' in result ? result.entries : [])) + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } +} diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 00e508b35af2..72d4e085ccbd 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -17,52 +17,28 @@ type DeclaredArtifact = | { label: string; path: string; kind: 'file'; maxBytes: number } | { label: string; path: string; kind: 'directory' } +function declaredFile(label: string, path: string | undefined, maxBytes: number) { + return path === undefined ? [] : [{ label, path, kind: 'file' as const, maxBytes }] +} + +/** One line per contribution family that names a file. */ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { + const c = manifest.contributes return [ - ...(manifest.icon - ? [ - { - label: 'icon', - path: manifest.icon, - kind: 'file' as const, - maxBytes: PLUGIN_ICON_MAX_BYTES - } - ] - : []), - ...(manifest.main - ? [ - { - label: 'worker entry', - path: manifest.main, - kind: 'file' as const, - maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES - } - ] - : []), - ...manifest.contributes.panels.map((panel) => ({ - label: `panel "${panel.id}" entry`, - path: panel.entry, - kind: 'file' as const, - maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES - })), - ...manifest.contributes.languagePacks.map((languagePack) => ({ - label: `language pack "${languagePack.locale}"`, - path: languagePack.path, - kind: 'file' as const, - maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES - })), - ...manifest.contributes.vmRecipes.map((recipe) => ({ - label: 'VM recipe', - path: recipe.path, - kind: 'file' as const, - maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES - })), - ...manifest.contributes.agents.map((agent) => ({ - label: 'agent profile', - path: agent.path, - kind: 'file' as const, - maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES - })) + ...declaredFile('icon', manifest.icon, PLUGIN_ICON_MAX_BYTES), + ...declaredFile('worker entry', manifest.main, PLUGIN_WORKER_ENTRY_MAX_BYTES), + ...c.panels.flatMap((panel) => + declaredFile(`panel "${panel.id}" entry`, panel.entry, PLUGIN_PANEL_ENTRY_MAX_BYTES) + ), + ...c.languagePacks.flatMap((pack) => + declaredFile(`language pack "${pack.locale}"`, pack.path, PLUGIN_LANGUAGE_PACK_MAX_BYTES) + ), + ...c.vmRecipes.flatMap((recipe) => + declaredFile('VM recipe', recipe.path, PLUGIN_VM_RECIPE_MAX_BYTES) + ), + ...c.agents.flatMap((agent) => + declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES) + ) ] } diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts index 719c23fcb874..1237b6c47fae 100644 --- a/src/main/plugins/plugin-content-hash.ts +++ b/src/main/plugins/plugin-content-hash.ts @@ -12,8 +12,8 @@ import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety' * are refused outright (installed trees must be self-contained). */ -const MAX_PLUGIN_FILES = 2_000 -const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 +export const MAX_PLUGIN_FILES = 2_000 +export const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 type PluginFile = { path: string; size: number } diff --git a/src/main/plugins/plugin-content-integrity.ts b/src/main/plugins/plugin-content-integrity.ts index 19f68cae680d..2fecb1459277 100644 --- a/src/main/plugins/plugin-content-integrity.ts +++ b/src/main/plugins/plugin-content-integrity.ts @@ -7,6 +7,12 @@ export type HashAddressedPluginContent = { export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string } +/** Whether a computed tree hash is the recorded identity. Early P0 installs used a + * 128-bit SHA-256 prefix as the directory name; new installs use the full digest. */ +export function pluginTreeHashMatches(actual: string, expected: string): boolean { + return actual === expected || (expected.length === 32 && actual.startsWith(expected)) +} + /** Dev trees are intentionally mutable; installed hash-addressed trees are not. */ export async function verifyHashAddressedPluginContent( plugin: HashAddressedPluginContent @@ -18,12 +24,7 @@ export async function verifyHashAddressedPluginContent( if (!actual.ok) { return { ok: false, error: actual.error } } - const matchesCurrentHash = actual.hash === plugin.contentHash - // Early P0 installs used a 128-bit SHA-256 prefix as the directory name. - // Honor that existing address while all new installs use the full digest. - const matchesLegacyPrefix = - plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash) - if (!matchesCurrentHash && !matchesLegacyPrefix) { + if (!pluginTreeHashMatches(actual.hash, plugin.contentHash)) { return { ok: false, error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})` diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index b6e19d3b9988..1e15d583f14d 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -10,6 +10,14 @@ import { PluginCommandRegistry } from './plugin-command-registry' import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' import type { KeybindingOverrides } from '../../shared/keybindings' +type ContentFamily = { + reconcile: ( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ) => Promise + error: (pluginKey: string) => string | null +} + export class PluginContentPackRegistry { readonly languagePacks: PluginLanguagePackRegistry readonly vmRecipes: PluginVmRecipeRegistry @@ -70,10 +78,9 @@ export class PluginContentPackRegistry { approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) && !this.isKilled(plugin.pluginKey) - const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically) - const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically) - this.commands.reconcile(discovered, approveAtomically, keybindings) - await Promise.all([languagePacks, vmRecipes]) + await Promise.all( + this.families(keybindings).map((family) => family.reconcile(discovered, approveAtomically)) + ) let foundNewError = false for (const pluginKey of approvedKeys) { @@ -95,10 +102,25 @@ export class PluginContentPackRegistry { } private registryError(pluginKey: string): string | null { - return ( - this.languagePacks.error(pluginKey) ?? - this.vmRecipes.error(pluginKey) ?? - this.commands.error(pluginKey) - ) + for (const family of this.families()) { + const error = family.error(pluginKey) + if (error !== null) { + return error + } + } + return null + } + + /** Every contribution family, in error-reporting order; a new family is one entry here. */ + private families(keybindings: KeybindingOverrides = {}): readonly ContentFamily[] { + return [ + this.languagePacks, + this.vmRecipes, + { + reconcile: async (discovered, isApproved) => + this.commands.reconcile(discovered, isApproved, keybindings), + error: (pluginKey) => this.commands.error(pluginKey) + } + ] } } diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts index fe2c9a3dc01b..5c361dd330bb 100644 --- a/src/main/plugins/plugin-instructional-content-integrity.ts +++ b/src/main/plugins/plugin-instructional-content-integrity.ts @@ -1,5 +1,6 @@ import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint' import { hashPluginTree } from './plugin-content-hash' +import { pluginTreeHashMatches } from './plugin-content-integrity' import type { ValidDiscoveredPlugin } from './plugin-discovery' /** Instructional bytes execute later, so every read must still match the tree @@ -19,10 +20,7 @@ export async function verifyInstructionalPluginContent( `plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}` ) } - const matches = - actual.hash === plugin.consentContentHash || - (plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash)) - if (!matches) { + if (!pluginTreeHashMatches(actual.hash, plugin.consentContentHash)) { throw new Error( `plugin ${plugin.pluginKey} instructional content changed since it was reviewed` ) diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts index df4092647724..48db107f3ced 100644 --- a/src/main/plugins/plugin-language-pack-registry.ts +++ b/src/main/plugins/plugin-language-pack-registry.ts @@ -3,94 +3,43 @@ import { pluginLanguageResourceId, type PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' import { PLUGIN_LANGUAGE_PACK_MAX_BYTES, readContainedPluginArtifactText } from './plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' import type { PluginContentVerifier } from './plugin-content-integrity' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { - isInvalidDiscoveredPlugin, - type DiscoveredPlugin, - type ValidDiscoveredPlugin -} from './plugin-discovery' - -const LANGUAGE_PACK_LOAD_CONCURRENCY = 4 - -type LanguageLoadResult = - | { pluginKey: string; packs: PluginLanguagePackRegistration[] } - | { pluginKey: string; error: string } -export class PluginLanguagePackRegistry { - private packs: PluginLanguagePackRegistration[] = [] - private readonly errors = new Map() - - constructor(private readonly contentVerifier: PluginContentVerifier) {} - - list(): readonly PluginLanguagePackRegistration[] { - return this.packs - } +type LanguagePackContribution = PluginManifest['contributes']['languagePacks'][number] - error(pluginKey: string): string | null { - return this.errors.get(pluginKey) ?? null - } - - async reconcile( - discovered: readonly DiscoveredPlugin[], - isApproved: (plugin: ValidDiscoveredPlugin) => boolean - ): Promise { - const candidates: ValidDiscoveredPlugin[] = [] - for (const plugin of discovered) { - if ( - !isInvalidDiscoveredPlugin(plugin) && - isApproved(plugin) && - plugin.manifest.contributes.languagePacks.length > 0 - ) { - candidates.push(plugin) - } - } - const results = await mapWithConcurrency( - candidates, - LANGUAGE_PACK_LOAD_CONCURRENCY, - async (plugin): Promise => { - try { - await this.contentVerifier.verify(plugin) - const packs = await Promise.all( - plugin.manifest.contributes.languagePacks.map(async (contribution) => { - const text = await readContainedPluginArtifactText( - plugin.rootDir, - contribution.path, - PLUGIN_LANGUAGE_PACK_MAX_BYTES - ) - const parsed = parsePluginLanguagePackArtifact(text) - if (!parsed.ok) { - throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) - } - const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const - return { - id, - resourceLanguage: pluginLanguageResourceId(id), - pluginKey: plugin.pluginKey, - locale: contribution.locale, - catalog: parsed.catalog - } - }) - ) - return { pluginKey: plugin.pluginKey, packs } - } catch (error) { - return { - pluginKey: plugin.pluginKey, - error: error instanceof Error ? error.message : String(error) - } +export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry< + LanguagePackContribution, + PluginLanguagePackRegistration +> { + constructor(contentVerifier: PluginContentVerifier) { + super({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.languagePacks, + load: async (plugin, contribution) => { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_LANGUAGE_PACK_MAX_BYTES + ) + const parsed = parsePluginLanguagePackArtifact(text) + if (!parsed.ok) { + throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) + } + const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const + return { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: plugin.pluginKey, + locale: contribution.locale, + catalog: parsed.catalog } } - ) - this.packs = results.flatMap((result) => ('packs' in result ? result.packs : [])) - this.errors.clear() - for (const result of results) { - if ('error' in result) { - this.errors.set(result.pluginKey, result.error) - } - } + }) } } diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts index d035841eb841..bfc719c61c8c 100644 --- a/src/main/plugins/plugin-marketplace-fetch.ts +++ b/src/main/plugins/plugin-marketplace-fetch.ts @@ -40,24 +40,32 @@ export async function fetchPluginMarketplace( export async function readPluginMarketplaceIndex( rootDirectory: string ): Promise { - const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + const raw = await readMarketplaceJson(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + return parseMarketplaceFile(PLUGIN_MARKETPLACE_FILENAME, () => pluginMarketplaceSchema.parse(raw)) +} + +async function readMarketplaceJson(rootDirectory: string, filename: string): Promise { const chunks: Buffer[] = [] let totalBytes = 0 - for await (const chunk of createReadStream(path)) { + for await (const chunk of createReadStream(join(rootDirectory, filename))) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) totalBytes += bytes.byteLength if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) { - throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`) + throw new Error(`${filename} exceeds its size limit`) } chunks.push(bytes) } + return parseMarketplaceFile(filename, () => + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) +} + +function parseMarketplaceFile(filename: string, parse: () => T): T { try { - return pluginMarketplaceSchema.parse( - JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) - ) + return parse() } catch (error) { throw new Error( - `invalid ${PLUGIN_MARKETPLACE_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + `invalid ${filename}: ${error instanceof Error ? error.message : String(error)}` ) } } diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 15b8e49cc0e5..a2909cf2ea04 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -137,9 +137,19 @@ export async function detectAgentsOnHost(host: AgentDetectionHost): Promise void>() + +/** Runs after each explicit agent refresh on this host, which can find a CLI installed since. */ +export function onAgentsRefreshedOnHost(listener: () => void): () => void { + agentRefreshListeners.add(listener) + return () => agentRefreshListeners.delete(listener) +} + export async function refreshAgentsOnHost(host: AgentDetectionHost): Promise { if (host.kind === 'ssh') { throw new SshHostNotServedError(host.connectionId) } - return refreshShellPathAndDetectAgents(host.context) + const result = await refreshShellPathAndDetectAgents(host.context) + agentRefreshListeners.forEach((listener) => listener()) + return result } diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 41ad22387d7c..249a553df092 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -183,18 +183,35 @@ describe('terminateProviderProcessTree', () => { ['live', 'live'], ['unverifiable', 'unverifiable'] ] as const)( - 'reports a %s descendant snapshot as-is and leaves the stopped root resumable', + 'reports a %s descendant snapshot after killing anchored child groups and the held root', async (observed, verdict) => { const target = child() + const signalProcessGroup = vi.fn() + const signalProcess = vi.fn() await expect( terminateProviderProcessTree(target, { site: 'codex-app-server-teardown', platform: 'darwin', - captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => observed + captureDescendants: async () => ({ + rootPgid: 1234, + descendants: [ + { pid: 2000, ppid: 1234, pgid: 2000, startedAt: 'now' }, + { pid: 3000, ppid: 2000, pgid: 2000, startedAt: 'now' } + ], + capturedAtMs: 1 + }), + terminateDescendants: async () => observed, + signalProcessGroup, + signalProcess }) ).resolves.toBe(verdict) - expect(target.kill).toHaveBeenLastCalledWith('SIGCONT') + expect(signalProcessGroup).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).not.toHaveBeenCalledWith(3000, 'SIGKILL') + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(signalProcess.mock.invocationCallOrder[0]).toBeLessThan( + target.kill.mock.invocationCallOrder.at(-1)! + ) } ) diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index 32820726feef..c177fda2ac52 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -23,6 +23,7 @@ export type ProviderProcessTeardownDeps = { /** Resolves true only when taskkill reports the whole tree terminated. */ terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + signalProcess?: (pid: number, signal: NodeJS.Signals) => void } function terminateDedicatedPosixGroup( @@ -66,41 +67,48 @@ async function terminatePosixTree( const terminate = deps.terminateDescendants ?? ((captured: DescendantSnapshot) => terminateDescendantSnapshotWithVerdict(captured)) - const verdict = await terminate(snapshot) - const descendantsExited = verdict === 'exited' - // A detached POSIX launch is the leader of its own process group. Group - // signalling reaches grandchildren even after they daemonise/reparent, - // while the stopped root and captured pgid make the ownership proof exact. - // The identity-gated descendant sweep remains the fallback for older hosts - // or launches that could not establish a dedicated group. - if (descendantsExited && snapshot.rootPgid === rootPid) { + let verdict: ProviderProcessTeardownVerdict = 'unverifiable' + try { + verdict = await terminate(snapshot) + } finally { + const signal = deps.signalProcess ?? process.kill const signalGroup = deps.signalProcessGroup ?? - ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) - let groupSignalled = false - try { - signalGroup(snapshot.rootPgid, 'SIGKILL') - groupSignalled = true - } catch { - // Already-gone is still the desired outcome, but nothing here killed it, - // and a crumb for a kill we never landed is a false render-process-gone suspect. + ((pgid: number, toSend: NodeJS.Signals) => process.kill(-pgid, toSend)) + // The stopped root cannot reap or release its direct children's PIDs. Its snapshot pins + // their identity until after this root is killed, even if a later table read fails. + for (const descendant of snapshot.descendants) { + if (descendant.ppid !== rootPid) { + continue + } + if (descendant.pgid === descendant.pid) { + try { + signalGroup(descendant.pid, 'SIGKILL') + } catch { + // An already exited group is safe to ignore. + } + } + try { + signal(descendant.pid, 'SIGKILL') + } catch { + // The root still has to be stopped when a child rejects a signal. + } } - if (groupSignalled) { - // Outside the try, as in terminateDedicatedPosixGroup: that catch is the - // already-gone contract, not a breadcrumb handler. - recordSelfInitiatedTreeKill({ - pid: snapshot.rootPgid, - site: deps.site, - scope: 'posix-process-group' - }) + if (snapshot.rootPgid === rootPid) { + try { + signalGroup(rootPid, 'SIGKILL') + recordSelfInitiatedTreeKill({ + pid: rootPid, + site: deps.site, + scope: 'posix-process-group' + }) + } catch { + // The held root is still addressed through its exact child handle below. + } } + child.kill('SIGKILL') } - if (!descendantsExited) { - child.kill('SIGCONT') - return verdict - } - child.kill('SIGKILL') - return 'exited' + return verdict } /** Stops every process owned by one provider launch before releasing its wrapper. */ 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 89c074d41665..20c49ee7250e 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 @@ -8,6 +8,7 @@ import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-fo import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import { inspectSpawnFileWindowsChildProcesses } from '../daemon/pty-subprocess/spawn-file-child-processes' import type { PtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import type { PtySubprocessOptions } from '../daemon/pty-subprocess' import { isGitForWindowsBashLauncherPath, resolveGitBashPath } from '../git-bash' @@ -151,4 +152,49 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => removeTreeSync(stagingRoot) } }, 60_000) + + it('sets aside a detached ssh-agent for adoption, but not a background job', async () => { + const opts: PtySubprocessOptions = { + sessionId: 'git-bash-detached-daemon-proof', + cols: 120, + rows: 30, + cwd: tmpdir(), + shellOverride: WINDOWS_GIT_BASH_SHELL + } + const env = createDaemonPtyEnvironment(opts) + const spawned = await spawnPlannedPane(createPtyShellLaunchPlan(opts, env), env, opts) + const proc = spawned.process + let output = '' + proc.onData((chunk) => { + output += chunk + }) + let agentPid = 0 + const inspect = (ignoreDetached: boolean) => + inspectSpawnFileWindowsChildProcesses(proc, spawned.shellPath, true, ignoreDetached) + try { + await vi.waitFor(() => expect(output).toContain('$'), { timeout: 20_000 }) + // ssh-agent forks, setsids off the console, and its Win32 parent exits. + proc.write('eval "$(ssh-agent -s)"; echo ORCA_AGENT_$SSH_AGENT_PID\r') + await vi.waitFor(() => expect(output).toMatch(/ORCA_AGENT_\d+/), { timeout: 10_000 }) + agentPid = Number(/ORCA_AGENT_(\d+)/.exec(output)?.[1]) + await vi.waitFor(async () => expect(await inspect(true)).toBe('no-children'), { + timeout: 10_000 + }) + // Close checks keep the job census: the daemon still counts there. + expect(await inspect(false)).not.toBe('no-children') + + // MSYS leaves the background sleep under an exited Win32 parent too, but on the console. + proc.write('sleep 60 &\r') + await vi.waitFor(async () => expect(await inspect(true)).toBe('children'), { + timeout: 10_000 + }) + } finally { + // From inside the pane: SSH_AGENT_PID is an MSYS pid, and it outlives the pane otherwise. + if (agentPid) { + proc.write(`kill %1; kill ${agentPid}\r`) + await new Promise((resolve) => setTimeout(resolve, 500)) + } + proc.kill() + } + }, 60_000) }) diff --git a/src/main/providers/local-filesystem-provider.ts b/src/main/providers/local-filesystem-provider.ts index 0b80f02e683f..2a556ba9260e 100644 --- a/src/main/providers/local-filesystem-provider.ts +++ b/src/main/providers/local-filesystem-provider.ts @@ -13,6 +13,11 @@ import type { RuntimeStore } from '../runtime/runtime-store-contract' import { runBundledRipgrepTextSearch } from '../ripgrep/bundled-ripgrep-text-search' import { resolveBundledRipgrepWslRouting } from '../ripgrep/bundled-ripgrep-wsl-routing' import type { IFilesystemProvider } from './filesystem-provider-contract' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../shared/file-write-disk-precondition' +import { writeLocalTextFileWithDiskPrecondition } from '../ipc/filesystem/local-disk-write-precondition' export type LocalFilesystemProviderOptions = { /** Read lazily so a call that fails earlier never touches the store. */ @@ -42,7 +47,14 @@ export type LocalFilesystemProvider = Required + > & { + /** `writeFile` that first refuses when disk no longer matches the caller's baseline. */ + writeFileWithDiskPrecondition( + filePath: string, + content: string, + precondition: FileWriteDiskPrecondition | undefined + ): Promise + } /** This machine's file operations behind the store's path authorization; build one per call. */ export function createLocalFilesystemProvider( @@ -59,6 +71,23 @@ export function createLocalFilesystemProvider( await mkdir(dirname(authorizedPath), { recursive: true }) await writeFile(authorizedPath, content, { flag: append ? 'a' : 'wx' }) } + const writeTextFile = async ( + filePath: string, + content: string, + precondition: FileWriteDiskPrecondition | undefined + ): Promise => { + const authorizedPath = await authorize(filePath) + try { + if ((await lstat(authorizedPath)).isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + return writeLocalTextFileWithDiskPrecondition(authorizedPath, content, precondition) + } return { readDir: async (dirPath, readOptions = {}) => { const entries = await readdir(await authorize(dirPath), { withFileTypes: true }) @@ -72,18 +101,9 @@ export function createLocalFilesystemProvider( }, readDocPreviewFile: readAuthorizedDocPreviewFile, writeFile: async (filePath, content) => { - const authorizedPath = await authorize(filePath) - try { - if ((await lstat(authorizedPath)).isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - await writeFile(authorizedPath, content, 'utf-8') + await writeTextFile(filePath, content, undefined) }, + writeFileWithDiskPrecondition: writeTextFile, writeFileBase64: (filePath, contentBase64) => writeBase64(filePath, contentBase64, false), writeFileBase64Chunk: writeBase64, stat: async (filePath) => { diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts index f934a7e7413a..72db972c80f6 100644 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty-child-process-verdict.test.ts @@ -2,6 +2,10 @@ import * as pty from 'node-pty' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { resolveForegroundMock } = vi.hoisted(() => ({ resolveForegroundMock: vi.fn() })) +const jobMembers = vi.hoisted(() => vi.fn()) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: jobMembers, isWindowsPtyJobReadable: () => true +})) const { confirmShellForegroundMock } = vi.hoisted(() => ({ confirmShellForegroundMock: vi.fn() @@ -18,7 +22,7 @@ import { inspectLocalPtyChildProcesses } from './local-pty-foreground-inspection' import { LocalPtyProvider } from './local-pty-provider' -import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' +import { ptyIncarnations, ptyProcesses, ptyShellPath } from './local-pty-provider-state' import { inspectPtyProviderProcess } from './pty-process-inspection' // Bare, so the retired pane's spawn file equals the recorded name (the path's basename). @@ -43,6 +47,7 @@ function registerPane(id: string, foreground: string | (() => string), shell?: s resume() {} } ptyProcesses.set(id, pane) + ptyIncarnations.set(id, `incarnation:${id}`) if (shell) { ptyShellPath.set(id, shell) } @@ -70,17 +75,23 @@ async function registerRetiredPane(id: string): Promise { interval: 10 }) ptyProcesses.set(id, term) + ptyIncarnations.set(id, `incarnation:${id}`) ptyShellPath.set(id, POSIX_SHELL) return term } beforeEach(() => { + // These fake foreground names model POSIX; Windows uses the owned-job fixture below. + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + jobMembers.mockReset() resolveForegroundMock.mockReset() resolveForegroundMock.mockResolvedValue({ available: true, processName: '/bin/zsh' }) }) afterEach(() => { + vi.restoreAllMocks() ptyProcesses.clear() + ptyIncarnations.clear() ptyShellPath.clear() }) @@ -154,6 +165,20 @@ describeOnPosix('inspectLocalPtyChildProcesses on a retired master', () => { describe('inspectPtyProviderProcess child-process evidence', () => { const provider = new LocalPtyProvider() + it('uses the owned Windows job without a static-name marker, preserving uncertain and replaced panes', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + registerPane('pty-win', 'powershell.exe', 'powershell.exe') + const inspect = () => inspectPtyProviderProcess(provider, 'pty-win', { expectedIncarnationId: 'incarnation:pty-win' }) + jobMembers.mockReturnValue(new Set([4242])) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'no-children', hasChildProcesses: false }) + jobMembers.mockReturnValue(new Set([4242, 4243])) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'children', hasChildProcesses: true }) + jobMembers.mockReturnValue(null) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'unverifiable', hasChildProcesses: true }) + jobMembers.mockImplementationOnce(() => { registerPane('pty-win', 'powershell.exe', 'powershell.exe'); return new Set([4242]) }) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'unverifiable', hasChildProcesses: true }) + }) + it('carries unverifiable evidence when the child read fails after foreground inspection', async () => { let reads = 0 registerPane( @@ -183,22 +208,6 @@ describe('inspectPtyProviderProcess child-process evidence', () => { expect(inspection.childProcessEvidence).toBe('children') }) - it('carries no-children evidence from the local inspectProcess operation', async () => { - registerPane('pty-idle', 'zsh', '/bin/zsh') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-idle') - expect(inspection.hasChildProcesses).toBe(false) - expect(inspection.childProcessEvidence).toBe('no-children') - }) - - it('carries children evidence from the local inspectProcess operation', async () => { - registerPane('pty-busy', 'vim', '/bin/zsh') - - const inspection = await inspectPtyProviderProcess(provider, 'pty-busy') - expect(inspection.hasChildProcesses).toBe(true) - expect(inspection.childProcessEvidence).toBe('children') - }) - it('refuses to pair one panes foreground with its replacements children', async () => { registerPane('pty-swapped', '/bin/zsh', '/bin/zsh') resolveForegroundMock.mockImplementation(async () => { diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index 8c3b0a6dccef..3393750c7058 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -38,7 +38,11 @@ import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows- * equals the recorded shell and would otherwise read as a real "nothing is running here". Ask the * descriptor before the name, because an unreadable PTY is not evidence that its children exited. */ -export async function inspectLocalPtyChildProcesses(id: string): Promise { +export async function inspectLocalPtyChildProcesses( + id: string, + fresh = true, + ignoreDetached = false +): Promise { const proc = ptyProcesses.get(id) if (!proc) { return 'no-children' @@ -47,10 +51,16 @@ export async function inspectLocalPtyChildProcesses(id: string): Promise { }) describe('hasChildProcesses', () => { + it('fences full inspection with the original PTY incarnation across awaits', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + await expect(provider.inspectProcess(id, { expectedIncarnationId: 'replacement' })).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) + let resolveScan!: (resolution: { available: boolean; processName: string }) => void + resolveAgentForegroundProcessMock.mockReturnValue(new Promise((resolve) => { resolveScan = resolve })) + const inspection = provider.inspectProcess(id, { expectedIncarnationId: incarnationId }) + exitCb?.({ exitCode: 0 }) + resolveScan({ available: true, processName: 'zsh' }) + await expect(inspection).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) + }) + it('returns false when foreground process matches shell', async () => { const { id } = await provider.spawn({ cols: 80, rows: 24 }) expect(await provider.hasChildProcesses(id)).toBe(false) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 2ba4aec6a8a6..a0111670bfd9 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -18,7 +18,7 @@ import { inspectLocalPtyChildProcesses } from './local-pty-foreground-inspection' import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import type { PtyProcessInspection } from './pty-process-inspection' +import type { PtyProcessInspection, PtyProcessInspectionOptions } from './pty-process-inspection' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { _resetPtyOwnerHostColorsForTest, @@ -29,6 +29,7 @@ import { clearPtyState, pendingLocalPtySpawns, ptyProcesses, + ptyIncarnations, resetLoadGeneration, type DataCallback, type ExitCallback @@ -144,12 +145,32 @@ export class LocalPtyProvider implements IPtyProvider { return hasLocalPtyChildProcesses(id) } - async inspectProcess(id: string): Promise { + async inspectProcess( + id: string, + options?: PtyProcessInspectionOptions + ): Promise { const proc = ptyProcesses.get(id) + const incarnation = ptyIncarnations.get(id) + if ( + !proc || + !incarnation || + (options?.expectedIncarnationId && options.expectedIncarnationId !== incarnation) + ) { + return { + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + } + } const foregroundProcess = await getLocalPtyForegroundProcess(id) - const childProcessEvidence = await inspectLocalPtyChildProcesses(id) + // A steady-state poll never decides adoption or close, so it may read the cached process table. + const childProcessEvidence = await inspectLocalPtyChildProcesses( + id, + options?.steadyState !== true, + options?.ignoreDetached === true + ) // Neither asynchronous inspection may publish a replacement pane's identity. - if (ptyProcesses.get(id) !== proc) { + if (ptyProcesses.get(id) !== proc || ptyIncarnations.get(id) !== incarnation) { return { foregroundProcess: null, hasChildProcesses: true, diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index e5eeb81214f0..0e7a543cf5c7 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -23,6 +23,9 @@ type CompletionSensitivePtyProvider = IPtyProvider & { export type PtyProcessInspectionOptions = { expectedIncarnationId?: PtyIncarnationId scanChildProcesses?: boolean + /** Adoption only: on Windows, a job member that left both the shell's tree and its console no + * longer counts. Never for close checks: closing a Windows pane kills its whole job. */ + ignoreDetached?: boolean /** A self-correcting cadence poll that reads only the process name: licenses a host to answer * from a cheap capture and OMIT evidence. Never set by a caller that consumes evidence. */ steadyState?: boolean diff --git a/src/main/providers/windows-console-attached-processes.ts b/src/main/providers/windows-console-attached-processes.ts index 8439156de9c0..2c5c2ac45de2 100644 --- a/src/main/providers/windows-console-attached-processes.ts +++ b/src/main/providers/windows-console-attached-processes.ts @@ -1,11 +1,22 @@ -import { fork, type ChildProcess } from 'node:child_process' +import { forkProcess } from '@orca/process-host/fork-process' +import type { SpawnedProcess } from '@orca/process-host/process-spec' const CONPTY_PROCESS_LIST_TIMEOUT_MS = 3_000 type ProcessListMessage = { consoleProcessList?: unknown } +type ForkConsoleListAgent = ( + modulePath: string, + args: string[], + options: { silent: true } +) => SpawnedProcess + +// Through the process-host chokepoint, which pins `windowsHide` on every fork. +const forkConsoleListAgent: ForkConsoleListAgent = (modulePath, args) => + forkProcess({ modulePath, args, stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }) + type WindowsConsoleAttachedProcessDeps = { - forkProcess?: typeof fork + forkProcess?: ForkConsoleListAgent resolveAgentPath?: () => string timeoutMs?: number } @@ -22,14 +33,14 @@ function resolveNodePtyConsoleListAgent(): string { * one console at a time -- which is why node-pty answers it from a separate * process, and why this still forks. * - * Only the candidate FILTER may use this. That filter exists to drop a - * descendant which detached from the console (`Start-Process`, a GUI child), and - * the job object deliberately still contains those, so the job cannot answer it - * -- see docs/windows-wsl-root-cause-plan.html, "Use B". + * Only one-off decisions may use this: the agent candidate FILTER, and the + * adoption shell-alone read setting aside detached daemons. Both + * drop a member which detached from the console (`Start-Process`, a GUI child, + * a setsid'd `ssh-agent`), and the job object deliberately still contains + * those, so the job cannot answer it -- see docs/windows-wsl-root-cause-plan.html, "Use B". * - * This is not the fork storm in #10857: it runs only when a recognized agent - * candidate already exists, not on every foreground poll. Bounding it to one - * pooled, supervised helper is the remaining half of that fix. + * This is not the fork storm in #10857: it never runs on a foreground poll. + * Bounding it to one pooled, supervised helper is the remaining half of that fix. */ export function readWindowsConsoleAttachedProcessIds( rootPid: number, @@ -38,9 +49,9 @@ export function readWindowsConsoleAttachedProcessIds( if (!Number.isSafeInteger(rootPid) || rootPid <= 0) { return Promise.resolve(null) } - let child: ChildProcess + let child: SpawnedProcess try { - child = (deps.forkProcess ?? fork)( + child = (deps.forkProcess ?? forkConsoleListAgent)( (deps.resolveAgentPath ?? resolveNodePtyConsoleListAgent)(), [String(rootPid)], { silent: true } diff --git a/src/main/providers/windows-shell-alone-in-job.test.ts b/src/main/providers/windows-shell-alone-in-job.test.ts new file mode 100644 index 000000000000..8cadbf68896f --- /dev/null +++ b/src/main/providers/windows-shell-alone-in-job.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import type { WindowsProcessIdentityRow } from '../windows/windows-process-table' +import { isWindowsShellAloneInJob } from './windows-shell-alone-in-job' + +const LAUNCHER = 'C:\\Program Files\\Git\\bin\\bash.exe' +const row = (pid: number, ppid: number, name = 'bash.exe'): WindowsProcessIdentityRow => ({ + pid, + ppid, + name +}) +// Measured on Windows: launcher -> exec stub -> interactive bash, then rc-started ssh-agent and +// `sleep 600 &`, both under exited Win32 parents; only the sleep stayed on the console. +const IDLE = [row(10, 1), row(11, 10), row(12, 11)] +const SSH_AGENT = row(20, 2184, 'ssh-agent.exe') +const SLEEP = row(21, 39140, 'sleep.exe') +const CONSOLE = new Set([10, 11, 12, 21]) + +function alone( + shellPath: string, + rows: WindowsProcessIdentityRow[], + consoleIds: ReadonlySet | null = CONSOLE +): { result: Promise; readConsole: ReturnType } { + const readConsole = vi.fn(async () => consoleIds) + const result = isWindowsShellAloneInJob( + 10, + shellPath, + new Set(rows.map((r) => r.pid)), + async () => rows, + readConsole + ) + return { result, readConsole } +} + +describe('isWindowsShellAloneInJob for adoption', () => { + it('sets aside a member that left both the shell tree and the console', async () => { + await expect(alone(LAUNCHER, [...IDLE, SSH_AGENT]).result).resolves.toBe(true) + await expect(alone(LAUNCHER, [...IDLE, SSH_AGENT, SLEEP]).result).resolves.toBe(false) + const pwsh = [row(10, 1, 'pwsh.exe'), SSH_AGENT] + await expect(alone('pwsh.exe', pwsh).result).resolves.toBe(true) + // `Start-Process` leaves the console but keeps its live parent: still running work. + const detachedChild = row(22, 10, 'claude.exe') + await expect(alone('pwsh.exe', [...pwsh, detachedChild]).result).resolves.toBe(false) + }) + + it('keeps every member when the console cannot be read, and reads it only for orphans', async () => { + await expect(alone(LAUNCHER, [...IDLE, SSH_AGENT], null).result).resolves.toBe(false) + const idle = alone(LAUNCHER, IDLE) + await expect(idle.result).resolves.toBe(true) + expect(idle.readConsole).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/windows-shell-alone-in-job.ts b/src/main/providers/windows-shell-alone-in-job.ts index 0256c28d223a..2910a7e58cc2 100644 --- a/src/main/providers/windows-shell-alone-in-job.ts +++ b/src/main/providers/windows-shell-alone-in-job.ts @@ -1,5 +1,9 @@ import { isGitForWindowsBashLauncherPath } from '../git-bash' import type { WindowsProcessIdentityRow } from '../windows/windows-process-table' +import { + buildProcessTableIndex, + collectDescendantsFromIndex +} from '../../shared/process-table-index' const MSYS_BASH_IMAGE = 'bash.exe' @@ -17,12 +21,19 @@ const MSYS_BASH_IMAGE = 'bash.exe' * Residual: a foreground bash running only builtins (a nested `bash`, a * subshell loop, a fork caught before its exec) is also a bash leaf, so it reads * as a shell at a prompt. + * + * With `readConsoleAttachedProcessIds`, a member that has left the terminal no + * longer counts: the job keeps everything ever started in the pane, including a + * daemon that detached itself (an rc-started `ssh-agent`). Parent links alone + * can't tell it apart, because MSYS fork/exec leaves live work under exited + * Win32 parents, but it also left the console, which live work does not. */ export async function isWindowsShellAloneInJob( shellPid: number, spawnedShellPath: string, jobProcessIds: ReadonlySet | null | undefined, - readIdentityTable: () => Promise + readIdentityTable: () => Promise, + readConsoleAttachedProcessIds?: () => Promise | null> ): Promise { if (!jobProcessIds?.has(shellPid)) { return false @@ -30,12 +41,20 @@ export async function isWindowsShellAloneInJob( if (jobProcessIds.size === 1) { return true } - if (!isGitForWindowsBashLauncherPath(spawnedShellPath)) { + const isLauncher = isGitForWindowsBashLauncherPath(spawnedShellPath) + if (!isLauncher && !readConsoleAttachedProcessIds) { return false } - const members = (await readIdentityTable()).filter((row) => jobProcessIds.has(row.pid)) + const rows = (await readIdentityTable()).filter((row) => jobProcessIds.has(row.pid)) // A member missing from the table exited or was never readable: no proof either way. - if (members.length !== jobProcessIds.size) { + if (rows.length !== jobProcessIds.size) { + return false + } + const members = await withoutDetachedOrphans(shellPid, rows, readConsoleAttachedProcessIds) + if (members.length === 1) { + return true + } + if (!isLauncher) { return false } const startMsByPid = new Map(members.map((row) => [row.pid, row.creationTimeMs])) @@ -57,6 +76,26 @@ export async function isWindowsShellAloneInJob( return true } +/** Drops members that are neither linked to the shell through live parents nor on its console. */ +async function withoutDetachedOrphans( + shellPid: number, + members: WindowsProcessIdentityRow[], + readConsoleAttachedProcessIds: (() => Promise | null>) | undefined +): Promise { + if (!readConsoleAttachedProcessIds) { + return members + } + const linked = new Set([ + shellPid, + ...collectDescendantsFromIndex(buildProcessTableIndex(members), shellPid).map((row) => row.pid) + ]) + if (members.every((row) => linked.has(row.pid))) { + return members + } + const attached = await readConsoleAttachedProcessIds().catch(() => null) + return attached ? members.filter((row) => linked.has(row.pid) || attached.has(row.pid)) : members +} + /** A child older than its parent carries a reused ppid; only checkable when the snapshot has start times. */ function startedBefore( childStartMs: number | undefined, diff --git a/src/main/pty-exit-proof.ts b/src/main/pty-exit-proof.ts new file mode 100644 index 000000000000..9b387108faf3 --- /dev/null +++ b/src/main/pty-exit-proof.ts @@ -0,0 +1,38 @@ +/** + * What retiring a PTY rests on. Retiring its ownership, its lease or its runtime record drops the + * only handle a later stop could use, so every retirement declares its evidence: a `PtyExitProof` + * naming what the owning host observed, or a `PtyRetirementWithoutProof` carrying the + * `unverifiable` reason. A declaration for review, not a capability: what keeps it honest is + * config/scripts/check-unproven-pty-retirement-ratchet.mjs, which counts every unproven + * retirement and every ownership drop that names no evidence. + */ +export type PtyExitProofSource = + /** The owning provider emitted this incarnation's exit, or the legacy relay reported it. */ + | 'exit-observed' + /** The owning provider's own inventory lacks it, or it answered "Session not found". */ + | 'absent' + /** The owning provider completed the shutdown it was asked for. */ + | 'shutdown-acknowledged' + /** A host-wide census answered `exited` for it. */ + | 'host-census' + /** A stop-and-wait confirmed it: an observed exit, or absence from the owning inventory. */ + | 'stop-confirmed' + +export class PtyExitProof { + private constructor(readonly source: PtyExitProofSource) {} + + static from(source: PtyExitProofSource): PtyExitProof { + return new PtyExitProof(source) + } +} + +export class PtyRetirementWithoutProof { + private constructor(readonly unverifiableReason: string) {} + + /** RATCHET: each call site is counted; a closed tab whose host can't answer still retires. */ + static because(unverifiableReason: string): PtyRetirementWithoutProof { + return new PtyRetirementWithoutProof(unverifiableReason) + } +} + +export type PtyRetirementEvidence = PtyExitProof | PtyRetirementWithoutProof diff --git a/src/main/repo-backfill-wsl-move.test.ts b/src/main/repo-backfill-wsl-move.test.ts index 4c18b5c36590..07711f5b7d5b 100644 --- a/src/main/repo-backfill-wsl-move.test.ts +++ b/src/main/repo-backfill-wsl-move.test.ts @@ -2,8 +2,9 @@ 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' -import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' +import { getRepoExecutionHostId } from '../shared/execution-host' import type { Repo } from '../shared/repo-types' +import type { RepoHostTarget } from '../shared/repo-host-identity' import { closeTestStores, createStore, testState } from './persistence-test-harness' import { wslMoveCutoverFixture as cutover } from './persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' import { seedWslMoveCutover } from './persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' @@ -69,9 +70,11 @@ describe('git remote identity enrichment under a WSL move', () => { ] const store = { getRepos: () => rows, - updateRepo: (id: string, updates: Partial, hostId?: ExecutionHostId) => { + updateRepo: (target: RepoHostTarget, updates: Partial) => { const row = rows.find( - (candidate) => candidate.id === id && getRepoExecutionHostId(candidate) === hostId + (candidate) => + candidate.id === target.id && + getRepoExecutionHostId(candidate) === getRepoExecutionHostId(target) ) return row ? Object.assign(row, updates) : null } diff --git a/src/main/repo-git-remote-avatar-refresh.test.ts b/src/main/repo-git-remote-avatar-refresh.test.ts index a787f5501bf3..7280ad0def00 100644 --- a/src/main/repo-git-remote-avatar-refresh.test.ts +++ b/src/main/repo-git-remote-avatar-refresh.test.ts @@ -4,6 +4,8 @@ import { deriveGitRemoteIdentity } from '../shared/git-remote-identity' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' import type { Repo } from '../shared/repo-types' +import type { RepoHostTarget } from '../shared/repo-host-identity' +import { repoTarget } from './persistence/repo-host-target-matcher.test-fixture' import { probeGitRemoteIdentity, type GitRemoteIdentityProbe } from './repo-git-remote-identity' import { enrichMissingRepoGitRemoteIdentities, @@ -39,8 +41,8 @@ function repo(overrides: Partial = {}): Repo { function storeFor(repos: Repo[]) { const getRepo = (id: string, hostId?: ExecutionHostId) => repos.find((row) => row.id === id && (!hostId || getRepoExecutionHostId(row) === hostId)) - const updateRepo = vi.fn((id: string, updates: Partial, hostId?: ExecutionHostId) => { - const current = getRepo(id, hostId) + const updateRepo = vi.fn((target: RepoHostTarget, updates: Partial) => { + const current = getRepo(target.id, getRepoExecutionHostId(target)) if (!current) { return null } @@ -223,11 +225,9 @@ it('repairs only the matching owner when repo IDs and paths collide across hosts const store = storeFor([local, ssh]) vi.mocked(probeGitRemoteIdentity).mockResolvedValue({ status: 'resolved', identity: identity() }) await refresh(store) - expect(store.updateRepo).toHaveBeenCalledWith( - 'app', - { repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) }, - 'ssh:build' - ) + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('app', 'ssh:build'), { + repoIcon: githubAvatarIcon({ owner: 'org-b', repo: 'app' }) + }) expect(projectHostSetupProjectionFromRepos([local, ssh]).projects).toHaveLength(1) }) @@ -250,11 +250,10 @@ it('keeps local metadata writes scoped when a same-id ssh:build row comes first' expect(foreign.repoIcon).toBe(foreignIcon) expect(local.gitRemoteIdentity).toEqual(identity()) expect(local.repoIcon).toEqual(githubAvatarIcon({ owner: 'org-b', repo: 'app' })) - expect(store.updateRepo).toHaveBeenCalledExactlyOnceWith( - 'app', - { gitRemoteIdentity: identity(), repoIcon: local.repoIcon }, - 'local' - ) + expect(store.updateRepo).toHaveBeenCalledExactlyOnceWith(repoTarget('app', 'local'), { + gitRemoteIdentity: identity(), + repoIcon: local.repoIcon + }) }) // Why this row is repaired where the `ssh:build` one above is not: a `runtime:` stamp reaches this @@ -282,8 +281,8 @@ it('repairs a same-id runtime-addressed row under its own stamp', async () => { } expect(runtimeRow).toMatchObject(repaired) expect(local).toMatchObject(repaired) - expect(store.updateRepo).toHaveBeenCalledWith('app', repaired, 'runtime:env-a') - expect(store.updateRepo).toHaveBeenCalledWith('app', repaired, 'local') + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('app', 'runtime:env-a'), repaired) + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('app', 'local'), repaired) }) it('does not write after a pending local probe becomes peer-owned', async () => { @@ -328,7 +327,9 @@ it('does not overwrite a custom icon selected while the probe is pending', async answer({ status: 'resolved', identity: identity() }) await flushRepoGitRemoteIdentityEnrichmentForTests() expect(row.repoIcon).toBe(selected) - expect(store.updateRepo).toHaveBeenCalledWith('app', { gitRemoteIdentity: identity() }, 'local') + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('app', 'local'), { + gitRemoteIdentity: identity() + }) }) it('never probes folder workspaces for an avatar repair', async () => { diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index 54350dda3d4f..a820b8bf817e 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRepoExecutionHostId, type ExecutionHostId } from '../shared/execution-host' +import { getRepoExecutionHostId } from '../shared/execution-host' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import type { Repo } from '../shared/repo-types' +import type { RepoHostTarget } from '../shared/repo-host-identity' +import { repoTarget } from './persistence/repo-host-target-matcher.test-fixture' import { type GitRemoteIdentityProbe, probeGitRemoteIdentity } from './repo-git-remote-identity' import { enrichMissingRepoGitRemoteIdentities, @@ -17,9 +19,8 @@ type RepoIdentityStore = { getRepos: () => Repo[] getRepo: (id: string) => Repo | undefined updateRepo: ( - id: string, - updates: Pick, 'gitRemoteIdentity'>, - hostId?: ExecutionHostId + target: RepoHostTarget, + updates: Pick, 'gitRemoteIdentity'> ) => Repo | null } @@ -47,18 +48,19 @@ function makeStore(...repos: Repo[]): RepoIdentityStore & { updateRepo: ReturnTy return { getRepos: () => repos, getRepo: (id) => repos.find((candidate) => candidate.id === id), - // Mirrors the real store: `hostId` is matched against the row's own stamp, so a write - // addressed to the wrong host finds no row (src/main/persistence/tracking-repos). - updateRepo: vi.fn((id, updates, hostId) => { - const target = repos.find( + // Mirrors the real store: the target's host is matched against the row's own stamp, so a + // write addressed to the wrong host finds no row (src/main/persistence/tracking-repos). + updateRepo: vi.fn((target: RepoHostTarget, updates) => { + const row = repos.find( (candidate) => - candidate.id === id && (!hostId || getRepoExecutionHostId(candidate) === hostId) + candidate.id === target.id && + getRepoExecutionHostId(candidate) === getRepoExecutionHostId(target) ) - if (!target) { + if (!row) { return null } - Object.assign(target, updates) - return target + Object.assign(row, updates) + return row }) } } @@ -170,11 +172,9 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { 'local', expect.objectContaining({ signal: expect.any(AbortSignal) }) ) - expect(store.updateRepo).toHaveBeenCalledWith( - 'repo-1', - { gitRemoteIdentity: remoteIdentity }, - 'runtime:env-a' - ) + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('repo-1', 'runtime:env-a'), { + gitRemoteIdentity: remoteIdentity + }) expect(repo.gitRemoteIdentity).toEqual(remoteIdentity) }) @@ -238,7 +238,9 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith('repo-1', { gitRemoteIdentity: null }, 'local') + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('repo-1', 'local'), { + gitRemoteIdentity: null + }) expect(repo.gitRemoteIdentity).toBeNull() }) @@ -273,11 +275,9 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store) await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(store.updateRepo).toHaveBeenCalledWith( - 'repo-1', - { gitRemoteIdentity: remoteIdentity }, - 'local' - ) + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('repo-1', 'local'), { + gitRemoteIdentity: remoteIdentity + }) }) it('does not re-probe a resolved identity before the refresh window elapses', async () => { @@ -330,11 +330,9 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged }) await drainEnrichmentSweep() - expect(store.updateRepo).toHaveBeenCalledWith( - 'repo-1', - { gitRemoteIdentity: movedIdentity }, - 'local' - ) + expect(store.updateRepo).toHaveBeenCalledWith(repoTarget('repo-1', 'local'), { + gitRemoteIdentity: movedIdentity + }) expect(repo.gitRemoteIdentity).toEqual(movedIdentity) expect(onChanged).toHaveBeenCalledTimes(1) }) @@ -502,7 +500,7 @@ describe('retiring probes for removed repos', () => { return { getRepos: () => live, getRepo: (id) => live.find((candidate) => candidate.id === id), - updateRepo: vi.fn((id, updates) => { + updateRepo: vi.fn(({ id }: RepoHostTarget, updates) => { const target = live.find((candidate) => candidate.id === id) if (!target) { return null diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index e222c33d9a97..6769b89d8f0d 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -5,6 +5,7 @@ import { type ExecutionHostId } from '../shared/execution-host' import type { Repo } from '../shared/repo-types' +import type { RepoHostTarget } from '../shared/repo-host-identity' import { githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' import { isUnresolvedSshHostAlias } from '../shared/git-remote-host-alias' import { getProjectProviderIdentity } from '../shared/project-host-setup-projection' @@ -28,9 +29,8 @@ type RepoIdentityStore = { getRepos(): Repo[] getRepo?(id: string, hostId?: ExecutionHostId): Repo | undefined updateRepo( - id: string, - updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>, - hostId?: ExecutionHostId + target: RepoHostTarget, + updates: Pick, 'gitRemoteIdentity' | 'repoIcon'> ): Repo | null } @@ -145,11 +145,10 @@ function writeIdentity( const icon = gitRemoteIdentity ? getAutomaticGitHubIconRefresh(current, gitRemoteIdentity) : undefined - // The row's own host, not the probe's: the store matches this argument against the row's stamp, - // so a `runtime:` row probed locally is only addressable here under that stamp. - const storeHostId = getRepoExecutionHostId(snapshot) + // The row's own identity, not the probe's host: a `runtime:` row probed locally is only + // addressable under its own stamp. const update = (updates: Pick, 'gitRemoteIdentity' | 'repoIcon'>): Repo | null => { - return store.updateRepo(snapshot.id, updates, storeHostId) + return store.updateRepo(snapshot, updates) } if (icon) { return !!update({ ...(writeRemote ? { gitRemoteIdentity } : {}), repoIcon: icon }) diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index c75f93e30b00..444071b97cf8 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -1,4 +1,5 @@ -import type { ExecutionHostId } from '../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../shared/execution-host' +import type { Repo } from '../shared/repo-types' import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity' import { gitExecFileAsync } from './git/runner' import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' @@ -71,3 +72,18 @@ export async function detectGitRemoteIdentity( const probe = await probeGitRemoteIdentity(repoPath, executionHostId, options) return probe.status === 'resolved' ? probe.identity : null } + +/** A registered folder's remote as git reports it now; the stored answer only when git is unreachable. */ +export async function readCurrentGitRemoteIdentity( + repo: Pick +): Promise> { + if (repo.kind === 'folder') { + return { gitRemoteIdentity: null } + } + // Local on purpose: both setup paths register and read the folder in this process. + const probe = await probeGitRemoteIdentity(repo.path, LOCAL_EXECUTION_HOST_ID) + if (probe.status === 'resolved') { + return { gitRemoteIdentity: probe.identity } + } + return { gitRemoteIdentity: probe.status === 'no-remote' ? null : repo.gitRemoteIdentity } +} diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 6e55edb32df5..813dbfae0641 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -139,7 +139,7 @@ it('rechecks current pins after renderer acknowledgement', async () => { it('keeps persistence writable when worktree teardown finds remaining terminal rows', async () => { const f = fixture() - f.store.updateRepo('repo1', { executionHostId: 'ssh:target' }) + f.store.updateRepo({ id: 'repo1' }, { executionHostId: 'ssh:target' }) f.store.setWorktreeMeta(ACK_WORKTREE, { hostId: 'ssh:target' }) f.store.setWorkspaceSession(f.store.getWorkspaceSession(), 'ssh:target') vi.spyOn(f.runtime, 'closeMobileSessionTab').mockResolvedValue(delegatedMobileSessionTabClose()) diff --git a/src/main/runtime/agent-session-acquisition-exit-transition.ts b/src/main/runtime/agent-session-acquisition-exit-transition.ts new file mode 100644 index 000000000000..a88ead3026c9 --- /dev/null +++ b/src/main/runtime/agent-session-acquisition-exit-transition.ts @@ -0,0 +1,55 @@ +import { isDeepStrictEqual } from 'node:util' +import { + agentSessionExecutionLocationsEqual, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { agentSessionLeaseIsReleased } from '../../shared/agent-session-lease-adjudication' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { StructuredAgentSessionAcquisitionReceipt } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import { assertFence, withLease } from './agent-session-lease-transitions' + +/** An unpublished start's observed exit belongs to its reservation, never a later owner. */ +export function recordAgentSessionAcquisitionExit( + record: AgentSessionRecord, + receipt: StructuredAgentSessionAcquisitionReceipt, + expectedFence: number, + now: number +): AgentSessionRecord { + assertFence(record.lease, expectedFence) + const lease = record.lease + const released = + agentSessionLeaseIsReleased(lease) && + lease.ownerProcess === null && + lease.runtimeFence === nextAgentSessionFence({ ...lease, runtimeFence: receipt.fence }) + const owner = lease.ownerProcess + const recovering = + lease.handoffStage === 'recovering' && + lease.handoffOperationId === null && + lease.runtimeFence === receipt.fence && + lease.reservedSpawnToken === receipt.spawnToken && + owner !== null && + receipt.process !== undefined && + isDeepStrictEqual({ ...owner, runtime: undefined }, { ...receipt.process, runtime: undefined }) + if ( + !agentSessionExecutionLocationsEqual(record.location, receipt.location) || + (!released && !recovering) + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...lease, + runtimeFence: released ? lease.runtimeFence : nextAgentSessionFence(lease), + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: now, + deathEvidence: { + kind: 'exit-observed', + detail: 'the failed acquisition provider root exited', + ownerFence: receipt.fence, + observedAt: now + } + }) +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index 7c750ca3dc12..c79265f088e5 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -42,6 +42,20 @@ export function agentSessionRecordIdentityFields( /** The fence a founded conversation starts at; every reservation moves it. */ export const AGENT_SESSION_FOUNDING_FENCE = 1 +/** A founded row with no reservation, owner, or provider handle has never started an agent. */ +export function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { + const { lease } = record + return ( + lease.claimStatus === 'released' && + lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && + lease.minimumNextFence === undefined && + lease.ownerProcess === null && + lease.reservedSpawnToken === null && + lease.handoffStage === null && + record.providerHandleChain.length === 0 + ) +} + /** * 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. diff --git a/src/main/runtime/agent-status-observed-pane-identity.ts b/src/main/runtime/agent-status-observed-pane-identity.ts index e26929f2090e..37fd2982d2f8 100644 --- a/src/main/runtime/agent-status-observed-pane-identity.ts +++ b/src/main/runtime/agent-status-observed-pane-identity.ts @@ -4,6 +4,11 @@ import { type ObservedAgentStatusPaneIdentity } from '../ipc/agent-status-ipc-boundary' import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' +import type { OrcaRuntimeService } from './orca-runtime' + +/** The runtime also links each conversation to the PTY execution that reported it. */ +type ObservedStatusRuntime = AgentStatusRuntimeEnrichment & + Partial> /** Bounded like the hook server's own per-pane maps; eviction only degrades a row to `unobserved`. */ const MAX_OBSERVED_PANES = 1024 @@ -48,22 +53,22 @@ export class AgentStatusObservedPaneIdentities { /** Buffers startup replay until PTY recovery has restored the runtime identities it fences. */ export class AgentStatusObservedPaneIdentityCapture { private readonly pending = new Map() - private runtime: AgentStatusRuntimeEnrichment | null = null + private runtime: ObservedStatusRuntime | null = null constructor(private readonly identities: AgentStatusObservedPaneIdentities) {} observe(enriched: EnrichedAgentHookEventPayload): void { if (this.runtime) { - recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, this.runtime) + recordObservedAgentStatusPaneIdentity(this.identities, enriched, this.runtime) return } this.pending.set(enriched.paneKey, enriched) } - attach(runtime: AgentStatusRuntimeEnrichment): void { + attach(runtime: ObservedStatusRuntime): void { this.runtime = runtime for (const enriched of this.pending.values()) { - recordObservedAgentStatusPaneIdentity(this.identities, enriched.paneKey, runtime) + recordObservedAgentStatusPaneIdentity(this.identities, enriched, runtime) } this.pending.clear() } @@ -72,12 +77,13 @@ export class AgentStatusObservedPaneIdentityCapture { /** Ingest-time capture: resolve the pane once, as the status arrives, and keep that answer. */ export function recordObservedAgentStatusPaneIdentity( identities: AgentStatusObservedPaneIdentities, - paneKey: string, - runtime: AgentStatusRuntimeEnrichment | undefined + event: EnrichedAgentHookEventPayload, + runtime: ObservedStatusRuntime | undefined ): void { - const binding = resolveAgentStatusBinding(paneKey, runtime) + runtime?.recordCliWriterConversation?.(event) + const binding = resolveAgentStatusBinding(event.paneKey, runtime) identities.record( - paneKey, + event.paneKey, binding.kind === 'unresolved' ? UNOBSERVED : { diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index 8c5119ed9dd9..4f5a4ce312fe 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -1,3 +1,4 @@ +import type { CliWriterObservation } from '../../agent-hooks/server/cli-writer-observation' import type { ExecutionHostScope } from '../../../shared/execution-host' import type { IPtyProvider } from '../../providers/pty-provider-contract' import type { PrepareClaudeAuth } from '../../ipc/pty/host-env/types' @@ -41,6 +42,7 @@ export type RuntimeConstructionDependencies = { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + readHookCliWriterObservation?: (row: AgentStatusIpcPayload) => CliWriterObservation | null /** `exitedSince`: also answer `exited` for an exit the hook store proved at or after it. */ checkHookAgentPresence?: ( paneKey: string, diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index f3c94858c6f1..084b398bc126 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -1,4 +1,7 @@ -import type { RuntimeConstructionDependencies } from './runtime-construction-dependencies' +import type { + RuntimeConstructionBindings, + RuntimeConstructionDependencies +} from './runtime-construction-dependencies' import type { OrcaRuntimeWithRuntimeId as IdentityState } from './runtime-identity-state' import type { OrcaRuntimeWithStopRequestedPtyIds as ServiceState } from './runtime-service-state' import type { OrcaRuntimeWithPreservedBranchCleanup as HostServices } from './runtime-host-services' @@ -55,6 +58,9 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo declare protected readonly getAgentProviderSessionRowsForPaneFn: HostServices['getAgentProviderSessionRowsForPaneFn'] declare protected readonly attestAgentHookCompatibilityAuthorityFn: HostServices['attestAgentHookCompatibilityAuthorityFn'] declare protected readonly retireAgentHookCompatibilityAuthorityFn: HostServices['retireAgentHookCompatibilityAuthorityFn'] + declare protected readonly readHookCliWriterObservationFn: + | RuntimeConstructionBindings['readHookCliWriterObservation'] + | null declare protected readonly checkHookAgentPresenceFn: HostServices['checkHookAgentPresenceFn'] declare protected readonly reconcileAgentStatusForEndedProcessFn: HostServices['reconcileAgentStatusForEndedProcessFn'] declare protected readonly forgetHookAgentExitFn: HostServices['forgetHookAgentExitFn'] @@ -206,6 +212,7 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.readHookCliWriterObservationFn = deps?.readHookCliWriterObservation ?? null this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.forgetHookAgentExitFn = deps?.forgetHookAgentExit ?? null diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts index 79725b7dcbe5..00d4ac308bbd 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-dispatch.test.ts @@ -2,6 +2,7 @@ import '../rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { + DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY, DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, DESKTOP_BROWSER_BRIDGE_SETTING_LABEL } from '../../../shared/desktop-browser-bridge-protocol' @@ -54,6 +55,7 @@ function orcadRuntime(options: { localBrowser?: boolean } = {}) { method, params, caller: context.caller, + signal: context.signal, hostEnabled: true, registry: getDesktopBrowserBridgeRegistry(runtime), localBrowserAvailable: options.localBrowser === true, @@ -73,7 +75,12 @@ const DESKTOP = { clientCapabilities: [DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY] } -async function attachDesktop(dispatcher: RpcDispatcher, options = DESKTOP) { +async function attachDesktop( + dispatcher: RpcDispatcher, + options: + | typeof DESKTOP + | (Omit & { clientCapabilities: string[] }) = DESKTOP +) { const events: Record[] = [] const done = dispatcher.dispatchStreaming( { @@ -287,4 +294,40 @@ describe('desktop browser bridge on orcad', () => { expect(response).toMatchObject({ ok: true, result: { url: 'local' } }) expect(events).toHaveLength(1) }) + + it.each([ + { + name: 'a cancel-capable desktop hears', + capabilities: [DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY], + cancels: true + }, + { name: 'an older desktop never hears', capabilities: [], cancels: false } + ])('$name that the CLI cancelled a forwarded command', async ({ capabilities, cancels }) => { + setDesktopBrowserBridgeHostEnabled(true) + const { runtime } = orcadRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: METHODS }) + const { events } = await attachDesktop(dispatcher, { + ...DESKTOP, + clientCapabilities: [DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, ...capabilities] + }) + const cli = new AbortController() + + const call = dispatcher.dispatch( + { + id: 'cli-goto', + authToken: 'owner', + method: 'browser.goto', + params: { url: 'https://example.com', worktree: 'path:/srv/app' } + }, + { signal: cli.signal } + ) + await vi.waitFor(() => expect(events).toHaveLength(2)) + const forwarded = ForwardedEvent.parse(events[1]) + cli.abort() + + await expect(call).resolves.toMatchObject({ ok: false }) + expect(events.slice(2)).toEqual( + cancels ? [{ type: 'cancel', requestId: forwarded.requestId }] : [] + ) + }) }) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts index 53cf377a205d..a0e98c890d35 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { DesktopBrowserBridgeEvent } from '../../../shared/desktop-browser-bridge-protocol' import { DesktopBrowserBridgeRegistry, @@ -31,4 +31,72 @@ describe('DesktopBrowserBridgeRegistry', () => { expect(events[0]).toMatchObject({ type: 'request', timeoutMs: 50_000 }) }) + + it('tells a cancel-capable desktop to drop a command the CLI cancelled', async () => { + const registry = new DesktopBrowserBridgeRegistry() + const events: DesktopBrowserBridgeEvent[] = [] + registry.attach({ + connectionId: 'c', + pairedDeviceId: 'd', + acceptsCancel: true, + emit: (event) => events.push(event) + }) + const cli = new AbortController() + + const pending = registry.forward( + { method: 'browser.click', params: {} }, + { timeoutMs: 60_000, signal: cli.signal } + ) + cli.abort() + + await expect(pending).rejects.toMatchObject({ code: 'browser_aborted' }) + const sent = events[0] + expect(sent?.type).toBe('request') + expect(events[1]).toEqual({ + type: 'cancel', + requestId: sent?.type === 'request' ? sent.requestId : '' + }) + }) + + it('never sends a cancel to an older desktop, which would drop its attachment on it', async () => { + const registry = new DesktopBrowserBridgeRegistry() + const events: DesktopBrowserBridgeEvent[] = [] + registry.attach({ connectionId: 'c', pairedDeviceId: 'd', emit: (event) => events.push(event) }) + const cli = new AbortController() + + const pending = registry.forward( + { method: 'browser.click', params: {} }, + { timeoutMs: 60_000, signal: cli.signal } + ) + cli.abort() + + await expect(pending).rejects.toMatchObject({ code: 'browser_aborted' }) + expect(events.map((event) => event.type)).toEqual(['request']) + }) + + it('cancels on the desktop when orcad stops waiting at its deadline', async () => { + vi.useFakeTimers() + try { + const registry = new DesktopBrowserBridgeRegistry() + const events: DesktopBrowserBridgeEvent[] = [] + registry.attach({ + connectionId: 'c', + pairedDeviceId: 'd', + acceptsCancel: true, + emit: (event) => events.push(event) + }) + const pending = registry.forward( + { method: 'browser.click', params: {} }, + { timeoutMs: 60_000 } + ) + pending.catch(() => {}) + + await vi.advanceTimersByTimeAsync(60_000) + + await expect(pending).rejects.toMatchObject({ code: 'browser_timeout' }) + expect(events.map((event) => event.type)).toEqual(['request', 'cancel']) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts index 0f10f2423cf1..3434827d0d75 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-registry.ts @@ -22,6 +22,8 @@ type Attachment = { attachmentId: string connectionId: string pairedDeviceId: string + /** True when the desktop declared it understands `cancel`; older desktops never receive one. */ + acceptsCancel?: boolean emit: (event: DesktopBrowserBridgeEvent) => void } @@ -75,12 +77,26 @@ export class DesktopBrowserBridgeRegistry { this.pending.delete(requestId) options.signal?.removeEventListener('abort', onAbort) } + let emitted = false + // Why: without this a command still queued on the desktop runs after the CLI gave up on it. + const cancelOnDesktop = (): void => { + if (!emitted || !attachment.acceptsCancel || !this.attachments.includes(attachment)) { + return + } + try { + attachment.emit({ type: 'cancel', requestId }) + } catch { + // The socket is gone, so the desktop's own detach revokes the command. + } + } const onAbort = (): void => { settle() + cancelOnDesktop() reject(new BrowserError('browser_aborted', 'The browser command was cancelled.')) } const timer = setTimeout(() => { settle() + cancelOnDesktop() reject( new BrowserError('browser_timeout', 'The desktop did not answer the browser command.') ) @@ -115,6 +131,7 @@ export class DesktopBrowserBridgeRegistry { ), ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}) }) + emitted = true } catch { this.pending.get(requestId)?.reject(unavailableError()) } diff --git a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts index 2a1ad031dde1..60cb60147c4b 100644 --- a/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts +++ b/src/main/runtime/desktop-browser-bridge/remote-cli-browser-scope.ts @@ -55,6 +55,34 @@ export function captureRemoteCliBrowserRevocation(): (webContentsId?: number) => } } +/** + * Captured where a browser command is queued: `run` puts the queued work back in exactly that + * scope (none for a desktop-local command), never the queue runner's, which may be another host's. + */ +export function captureRemoteCliBrowserScope(): { + bridged: boolean + /** Fires when the host behind the command is revoked. */ + revoked?: AbortSignal + run(work: () => T): T +} { + const active = scope.getStore() + return { + bridged: active !== undefined, + revoked: active?.revoked, + run: (work) => (active ? scope.run(active, work) : scope.exit(work)) + } +} + +/** Runs `work` in no host's scope, so long-lived resources it creates never inherit one. */ +export function runOutsideRemoteCliBrowserScope(work: () => T): T { + return scope.exit(work) +} + +/** Throws inside a bridged command once it was revoked or `webContentsId` stopped being admitted. */ +export function assertRemoteCliBrowserAccess(webContentsId?: number): void { + captureRemoteCliBrowserRevocation()(webContentsId) +} + /** Null outside a bridged command; inside one, the scoped workspace or a refusal. */ export function resolveRemoteCliBrowserScopeSelector( selector: string diff --git a/src/main/runtime/files/file-mutations.ts b/src/main/runtime/files/file-mutations.ts index 7f8f1858d584..08071726c3d5 100644 --- a/src/main/runtime/files/file-mutations.ts +++ b/src/main/runtime/files/file-mutations.ts @@ -11,6 +11,10 @@ import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-path import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root' import { constants, copyFile, mkdir, rm } from 'node:fs/promises' import { dirname } from 'node:path' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../../shared/file-write-disk-precondition' type RuntimeFileMutationHost = Pick< RuntimeFileCommandHost, @@ -26,8 +30,9 @@ export class RuntimeFileMutations { content: string, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { + expectedExecutionHostId?: string, + diskPrecondition?: FileWriteDiskPrecondition + ): Promise { const target = await resolveFileExplorerPath(this.host, worktreeSelector, relativePath) assertRuntimeFileMutationExpectation( target.executionHostId, @@ -35,10 +40,13 @@ export class RuntimeFileMutations { expectedSshTargetId, expectedSshConnectionGeneration ) - await runNativeChatScratchSave(getProfileUserDataPath(), target.path, () => - requireRuntimeFileProvider(target, this.host).writeFile(target.path, content) + return runNativeChatScratchSave(getProfileUserDataPath(), target.path, () => + requireRuntimeFileProvider(target, this.host).writeFileWithDiskPrecondition( + target.path, + content, + diskPrecondition + ) ) - return { ok: true } } async writeFileExplorerFileBase64( diff --git a/src/main/runtime/folder-workspace-pty-teardown.ts b/src/main/runtime/folder-workspace-pty-teardown.ts index 949b65b17ab5..17965806ec1a 100644 --- a/src/main/runtime/folder-workspace-pty-teardown.ts +++ b/src/main/runtime/folder-workspace-pty-teardown.ts @@ -1,5 +1,6 @@ import { killAllProcessesForWorktree, type WorktreeTeardownRuntime } from './worktree-teardown' import type { IPtyProvider } from '../providers/pty-provider-contract' +import { teardownStops } from './owned-session-teardown-policy' export type FolderWorkspacePtyTeardownDeps = { runtime: WorktreeTeardownRuntime @@ -28,7 +29,7 @@ export async function teardownFolderWorkspacePtys( onPtyStopped: deps.onPtyStopped ?? undefined, // A structured session is on no PTY surface, so the sweeps above leave it attached to a // workspace this removal is about to forget. Closed best-effort, exactly like those PTYs. - closeStructuredSessions: true, + closeStructuredSessions: teardownStops('folder-removal', 'structured-chat'), ...(connectionId ? { includeProviderInventory: false, includeLocalRegistry: false } : {}) }).catch((error) => { console.warn(`[worktree-teardown] failed for ${worktreeId}:`, error) diff --git a/src/main/runtime/orca-runtime-files-mock-registry.ts b/src/main/runtime/orca-runtime-files-mock-registry.ts index 276c16cf90bf..ab53ab5a4d2c 100644 --- a/src/main/runtime/orca-runtime-files-mock-registry.ts +++ b/src/main/runtime/orca-runtime-files-mock-registry.ts @@ -46,7 +46,9 @@ export function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } -export async function fsModuleMock() { +export async function fsModuleMock(): Promise< + Omit & { watch: typeof watchMock } +> { const actual = await vi.importActual('fs') return { ...actual, diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index 42dc46ac7331..a556b659a277 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -53,6 +53,8 @@ import { getManagedServerActions } from './managed-server-actions-registry' import type { WorktreeSortOrderUpdate } from '../../shared/worktree/sort-order-update' import { isDesktopBrowserBridgeHostEnabled } from './desktop-browser-bridge/desktop-browser-bridge-registry' import { DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY } from '../../shared/desktop-browser-bridge-protocol' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../shared/workspace-unread-raise-capability' type RuntimeStatusHost = { getAvailableAuthoritativeWindow(): unknown @@ -142,6 +144,11 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime if (isDesktopBrowserBridgeHostEnabled()) { capabilities.push(DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY) } + // Switch point: this host raises the workspace unread flag itself. The env lets the e2e play an + // older host without keeping an old binary. + if (ORCA_SIDEBAR_INPUTS_IS_MAIN && process.env.ORCA_E2E_OLDER_UNREAD_HOST !== '1') { + capabilities.push(WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY) + } // Why not a static capability: dev trees and `orca serve` installs may carry no // out/mobile-web, and advertising a bundle this install cannot produce would promise a // download that only ever answers mobile_web_bundle_unavailable. diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index c1e5f2522ff7..04af0407e645 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -104,7 +104,7 @@ describe('structured Codex folder trust', () => { }) describe('structured agent-session create intent', () => { - it('returns the recorded owner before CLI source lookup, new-launch support or account preparation', async () => { + it('returns the recorded owner, once no terminal still runs it, before launch support or account preparation', async () => { const record = agentSessionRecordFixture() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: early ownership admission only reads the host's record list. setStructuredAgentSessionHost({ @@ -114,18 +114,15 @@ describe('structured agent-session create intent', () => { const support = vi .spyOn(runtime, 'getStructuredAgentSessionCreateSupport') .mockRejectedValue(new Error('fresh launch is unavailable')) - const sourceRows = vi.spyOn(runtime, 'getAgentProviderSessionRows').mockImplementation(() => { - throw new Error('source is unavailable') - }) + const writers = vi + .spyOn(runtime, 'assertStructuredAdoptionTerminalWritersExited') + .mockResolvedValue() await expect( runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'new-chat', clientOperationId: 'operation-1' }, worktree: 'id:another-workspace', agent: 'claude', - resumeFrom: { - providerSessionId: 'provider-session-alpha-1', - cliSource: { workspaceId: 'another-workspace', paneKey: 'missing-pane' } - } + resumeFrom: { providerSessionId: 'provider-session-alpha-1' } }) ).rejects.toMatchObject({ refusal: { @@ -137,7 +134,41 @@ describe('structured agent-session create intent', () => { } }) expect(support).not.toHaveBeenCalled() - expect(sourceRows).not.toHaveBeenCalled() + expect(writers).toHaveBeenCalledWith('claude', 'provider-session-alpha-1', undefined) + }) + + it('refuses a switch out of a live terminal before naming the chat that owns the conversation', async () => { + const record = agentSessionRecordFixture() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: early ownership admission only reads the host's record list. + setStructuredAgentSessionHost({ + deps: { store: { listRecords: () => [record] } } + } as unknown as StructuredAgentSessionHost) + const runtime = new OrcaRuntimeService() + const writers = vi.spyOn(runtime, 'assertStructuredAdoptionTerminalWritersExited') + const intent = () => + runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'new-chat', clientOperationId: 'operation-1' }, + worktree: 'id:another-workspace', + agent: 'claude', + resumeFrom: { + providerSessionId: 'provider-session-alpha-1', + cliSource: { workspaceId: 'another-workspace', paneKey: 'pane-1' } + } + }) + const liveWriter = Object.assign(new Error('live'), { + refusal: { code: 'agent_session_conflict', details: { reason: 'conversationHeldElsewhere' } } + }) + writers.mockRejectedValueOnce(liveWriter) + await expect(intent()).rejects.toBe(liveWriter) + // At rest, the switch opens the owning chat as before. + writers.mockResolvedValueOnce() + await expect(intent()).rejects.toMatchObject({ + refusal: { details: { owner: { sessionId: record.sessionId } } } + }) + expect(writers).toHaveBeenCalledWith('claude', 'provider-session-alpha-1', { + workspaceId: 'another-workspace', + paneKey: 'pane-1' + }) }) it('pins the selected Codex launch home after normal launch preparation', async () => { diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 597edda24d71..22944b86cfed 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -406,7 +406,7 @@ const store = { getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }), mergeRetiredWorktreeNames: () => false, addRepo: () => {}, - updateRepo: (id: string, updates: Record) => + updateRepo: ({ id }: { id: string }, updates: Record) => ({ ...store.getRepo(id), ...updates diff --git a/src/main/runtime/orca-runtime-tests/project-setup-folder-remote-identity.spec.ts b/src/main/runtime/orca-runtime-tests/project-setup-folder-remote-identity.spec.ts new file mode 100644 index 000000000000..a506aa121543 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/project-setup-folder-remote-identity.spec.ts @@ -0,0 +1,181 @@ +import { realpathSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + OrcaRuntimeService, + execFileSync, + getRepoUpstreamMock, + join, + mkdtemp, + projectHostSetupProjectionFromRepos, + rm, + tmpdir +} from '../orca-runtime-test-mocks.spec' +import { store } from '../orca-runtime-test-fixtures.spec' +import type { Repo } from '../../../shared/repo-types' +import type { RuntimeStore } from '../runtime-store-contract' + +function createRuntime(repos: Repo[]): OrcaRuntimeService { + const runtimeStore: RuntimeStore = { + ...store, + getRepos: () => [...repos], + addRepo: (repo: Repo) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + updateRepo: ({ id }, updates) => { + const repo = repos.find((entry) => entry.id === id) + if (!repo) { + return null + } + Object.assign(repo, updates) + return { ...repo } + }, + removeProjectForHost: (id: string) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index !== -1) { + repos.splice(index, 1) + } + }, + getProjects: () => [...projectHostSetupProjectionFromRepos(repos).projects], + getProjectHostSetups: () => [...projectHostSetupProjectionFromRepos(repos).setups] + } + return new OrcaRuntimeService(runtimeStore) +} + +async function withGitFolder( + remotes: Record, + run: (path: string) => Promise +): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-remote-')) + try { + execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' }) + for (const [name, url] of Object.entries(remotes)) { + execFileSync('git', ['remote', 'add', name, url], { cwd: tempRoot, stdio: 'ignore' }) + } + await run(tempRoot) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } +} + +const repoA = { provider: 'github' as const, owner: 'acme', repo: 'repo-a' } + +describe('OrcaRuntimeService project setup of an existing folder (#30663)', () => { + it('refuses to relabel a folder whose own GitHub remote names another repository', async () => { + const repos: Repo[] = [] + getRepoUpstreamMock.mockResolvedValueOnce(null) + const runtime = createRuntime(repos) + + await withGitFolder({ origin: 'https://github.com/acme/repo-b.git' }, async (path) => { + await expect( + runtime.setupProjectExistingFolder({ + projectId: 'github:acme/repo-a', + projectProviderIdentity: repoA, + hostId: 'runtime:env-1', + path, + kind: 'git' + }) + ).rejects.toThrow(/points at acme\/repo-b, not acme\/repo-a/) + }) + + // No fabricated upstream and no row left under project A. + expect(repos).toHaveLength(0) + }) + + it('links a fork whose upstream remote names the project', async () => { + const repos: Repo[] = [] + getRepoUpstreamMock.mockResolvedValueOnce(null) + const runtime = createRuntime(repos) + + await withGitFolder( + { + origin: 'https://github.com/me/repo-a-fork.git', + upstream: 'https://github.com/acme/repo-a.git' + }, + async (path) => { + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:acme/repo-a', + projectProviderIdentity: repoA, + hostId: 'runtime:env-1', + path, + kind: 'git' + }) + expect(result.project.id).toBe('github:acme/repo-a') + } + ) + }) + + it('still links a folder whose remote is not on GitHub', async () => { + const repos: Repo[] = [] + getRepoUpstreamMock.mockResolvedValueOnce(null) + const runtime = createRuntime(repos) + + await withGitFolder({ origin: 'https://gitlab.example.test/acme/repo-a.git' }, async (path) => { + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:acme/repo-a', + projectProviderIdentity: repoA, + hostId: 'runtime:env-1', + path, + kind: 'git' + }) + expect(result.project.id).toBe('github:acme/repo-a') + expect(repos[0]?.upstream).toEqual({ owner: 'acme', repo: 'repo-a' }) + }) + }) + + it('links a folder whose remote host is an SSH alias for the project repository', async () => { + const repos: Repo[] = [] + getRepoUpstreamMock.mockResolvedValueOnce(null) + const runtime = createRuntime(repos) + + await withGitFolder({ origin: 'git@github-work:acme/repo-a.git' }, async (path) => { + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:acme/repo-a', + projectProviderIdentity: repoA, + hostId: 'runtime:env-1', + path, + kind: 'git' + }) + expect(result.project.id).toBe('github:acme/repo-a') + }) + }) + + it('judges an already-registered folder by its current remotes, not the stored ones', async () => { + const repos: Repo[] = [] + const runtime = createRuntime(repos) + + await withGitFolder( + { + origin: 'https://github.com/me/repo-a-fork.git', + upstream: 'https://github.com/acme/repo-a.git' + }, + async (path) => { + // Registered before the user added the `upstream` remote the refusal recommends. + repos.push({ + id: 'repo-registered', + path: realpathSync(path), + displayName: 'repo-a-fork', + badgeColor: '#000000', + addedAt: 1, + kind: 'git', + executionHostId: 'runtime:env-1', + upstream: null, + gitRemoteIdentity: { + canonicalKey: 'github.com/me/repo-a-fork', + remoteName: 'origin', + remoteUrl: 'https://github.com/me/repo-a-fork.git' + } + }) + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:acme/repo-a', + projectProviderIdentity: repoA, + hostId: 'runtime:env-1', + path, + kind: 'git' + }) + expect(result.project.id).toBe('github:acme/repo-a') + expect(result.repo.id).toBe('repo-registered') + } + ) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts index 81c9e856da79..9da1ae1583af 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts @@ -43,7 +43,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -123,7 +123,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -379,7 +379,7 @@ describe('OrcaRuntimeService', () => { const colorStore = { ...store, getRepos: () => [existing], - updateRepo: (id: string, repoUpdates: Record) => { + updateRepo: ({ id }: { id: string }, repoUpdates: Record) => { updates.push({ id, updates: repoUpdates }) return upgraded as never } diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts index babf21eac288..c0e313c2e937 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations.spec.ts @@ -130,7 +130,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -207,7 +207,7 @@ describe('OrcaRuntimeService', () => { getRepos: () => [...repos] as never, addRepo: (repo: Record) => repos.push(repo), getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const repo = repos.find((entry) => entry.id === id) if (!repo) { return null @@ -259,7 +259,7 @@ describe('OrcaRuntimeService', () => { getRepos: () => [...repos] as never, addRepo: (repo: Record) => repos.push(repo), getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const repo = repos.find((entry) => entry.id === id) if (!repo) { return null @@ -348,7 +348,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -465,7 +465,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -506,7 +506,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null @@ -539,7 +539,7 @@ describe('OrcaRuntimeService', () => { repos.push(repo) }, getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, - updateRepo: (id: string, updates: Record) => { + updateRepo: ({ id }: { id: string }, updates: Record) => { const index = repos.findIndex((repo) => repo.id === id) if (index === -1) { return null diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 71835379b812..a8474be39d78 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ await import('./orca-runtime-tests/hooks-and-hosted-review-part-02.spec') await import('./orca-runtime-tests/hooks-and-hosted-review-part-03.spec') await import('./orca-runtime-tests/repository-project-operations.spec') await import('./orca-runtime-tests/repository-project-operations-part-02.spec') +await import('./orca-runtime-tests/project-setup-folder-remote-identity.spec') await import('./orca-runtime-tests/pty-title-status.spec') await import('./orca-runtime-tests/terminal-side-effect-facts.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5da66eacf76d..2a827d976a33 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -37,6 +37,10 @@ import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-l import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey' import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import { + installStructuredAgentAvailability, + type StructuredAgentAvailabilityReader +} from './structured-agent-availability' import { PairedServerRowsProducer } from './sidebar-inputs/paired-server-rows-producer' import { RepoIconDataUrls } from './sidebar-inputs/repo-icon-data-urls' import { WorkspaceRowsProducer } from './sidebar-inputs/workspace-rows-producer' @@ -178,6 +182,14 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { publishWorkspaceViewChanges(this.store, (event) => this.emitClientEvent(event)) } + private structuredAgentAvailability?: ReturnType + + /** This host's agent health snapshot and every replacement, from first use; returns the release. */ + subscribeStructuredAgentAvailability(read: StructuredAgentAvailabilityReader): () => void { + this.structuredAgentAvailability ??= installStructuredAgentAvailability(this.store, this) + return this.structuredAgentAvailability.subscribe(read) + } + /** Whether a window owns the layout and can show a launch's tab ahead of its process. */ canPublishAgentLaunchTab(): boolean { return Boolean(this.notifier?.publishAgentLaunchTab && this.getAvailableAuthoritativeWindow()) diff --git a/src/main/runtime/owned-session-teardown-policy.test.ts b/src/main/runtime/owned-session-teardown-policy.test.ts new file mode 100644 index 000000000000..fffbd4fa1095 --- /dev/null +++ b/src/main/runtime/owned-session-teardown-policy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEvent } from '../../shared/runtime-client-events' +import type { RuntimeNotifier } from './runtime-notifier-contract' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { OWNED_SESSION_KINDS, OWNED_SESSION_TEARDOWN } from './owned-session-teardown-policy' +import { + getRuntimeBrowserPageRegistry, + type RuntimeBrowserClientPage +} from './runtime-browser-page-registry' + +// Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('./orca-runtime-test-fixtures.spec') + +function makeRuntime() { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const runtime = new OrcaRuntimeService(runtimeStore) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => false, + stopAndWait: async () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const events: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => events.push(event)) + const sleepPhases = (): string[] => + events.flatMap((event) => (event.type === 'worktreeTerminalSleepState' ? [event.phase] : [])) + return { runtime, runtimeStore, sleepPhases } +} + +describe('owned-session teardown policy', () => { + it('pins the decided policy for every kind and entry point', () => { + const removal = { + pty: 'stop', + 'structured-chat': 'stop', + 'host-browser': 'stop', + launch: 'stop' + } + expect(OWNED_SESSION_KINDS).toEqual(['pty', 'structured-chat', 'host-browser', 'launch']) + expect(OWNED_SESSION_TEARDOWN).toEqual({ + 'worktree-removal': removal, + 'folder-removal': removal, + 'project-removal': removal, + sleep: { + pty: 'stop', + 'structured-chat': 'stop-pending-28445', + 'host-browser': 'retain', + launch: 'retain' + }, + // Loss of contact is never evidence of exit. + disconnect: { + pty: 'retain', + 'structured-chat': 'retain', + 'host-browser': 'retain', + launch: 'retain' + } + }) + }) + + it('sleeps the terminals on the host when no renderer can run the sleep flow', async () => { + const { runtime, sleepPhases } = makeRuntime() + + await runtime.sleepManagedWorktree(`id:${TEST_WORKTREE_ID}`) + + // Settled: the host now refuses an automatic spawn into the slept worktree. + expect(sleepPhases()[0]).toBe('started') + await expect( + runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { refuseSleptWorktree: true }) + ).rejects.toThrow() + }) + + it('never reports a headless sleep whose stop is unproven as done', async () => { + const { runtime } = makeRuntime() + vi.spyOn(runtime, 'sleepTerminalsForWorktree').mockResolvedValue({ + stopped: 0, + stoppedPtyIds: [], + livePtyIds: ['pty-1'], + postStopVerified: false, + postStopFailure: 'terminal_worktree_sleep_still_live', + remainingLivePtyIds: ['pty-1'] + }) + + await expect(runtime.sleepManagedWorktree(`id:${TEST_WORKTREE_ID}`)).rejects.toThrow( + 'terminal_worktree_sleep_still_live' + ) + }) + + it('leaves the sleep to an attached renderer', async () => { + const { runtime, sleepPhases } = makeRuntime() + const sleepWorktree = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: sleepManagedWorktree reads only sleepWorktree. + runtime.setNotifier({ sleepWorktree } as unknown as RuntimeNotifier) + + await runtime.sleepManagedWorktree(`id:${TEST_WORKTREE_ID}`) + + expect(sleepWorktree).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(sleepPhases()).toEqual([]) + }) + + it("project removal finds a headless page's workspace through the browser bridge", async () => { + const { runtime, runtimeStore } = makeRuntime() + const [repo] = runtimeStore.getRepos() + vi.spyOn(runtimeStore, 'getRepos').mockReturnValue([]) + vi.spyOn(getRuntimeBrowserPageRegistry(runtime), 'listPages').mockReturnValue([]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: release reads only tabWorktreeIds. + runtime.setAgentBrowserBridge({ + tabWorktreeIds: () => [TEST_WORKTREE_ID] + } as unknown as AgentBrowserBridge) + const release = vi.spyOn(runtime, 'releaseRemovedWorktreeHostSessions') + + await runtime.stopTerminalsForProject(repo, { closeStructuredSessions: true }) + + expect(release).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'project-removal') + }) + + it.each([ + { survivingRow: false, released: [TEST_WORKTREE_ID] }, + // Page records carry no host, so a same-id row on another host keeps every page. + { survivingRow: true, released: [] } + ])( + "project removal releases its workspaces' host pages (surviving row: $survivingRow)", + async ({ survivingRow, released }) => { + const { runtime, runtimeStore } = makeRuntime() + const repos = runtimeStore.getRepos() + vi.spyOn(runtimeStore, 'getRepos').mockReturnValue(survivingRow ? repos : []) + vi.spyOn(getRuntimeBrowserPageRegistry(runtime), 'listPages').mockReturnValue([ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only workspaceId is read. + { workspaceId: TEST_WORKTREE_ID } as RuntimeBrowserClientPage, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only workspaceId is read. + { workspaceId: 'other-repo::/elsewhere' } as RuntimeBrowserClientPage + ]) + const release = vi.spyOn(runtime, 'releaseRemovedWorktreeHostSessions') + + await runtime.stopTerminalsForProject(repos[0], { closeStructuredSessions: true }) + + expect(release.mock.calls.map(([worktreeId]) => worktreeId)).toEqual(released) + } + ) +}) diff --git a/src/main/runtime/owned-session-teardown-policy.ts b/src/main/runtime/owned-session-teardown-policy.ts new file mode 100644 index 000000000000..02fd466c9187 --- /dev/null +++ b/src/main/runtime/owned-session-teardown-policy.ts @@ -0,0 +1,53 @@ +/** Everything a workspace owns on its execution host that outlives a client. */ +export const OWNED_SESSION_KINDS = ['pty', 'structured-chat', 'host-browser', 'launch'] as const +export type OwnedSessionKind = (typeof OWNED_SESSION_KINDS)[number] + +export type OwnedSessionTeardownEntryPoint = + | 'worktree-removal' + | 'folder-removal' + | 'project-removal' + | 'sleep' + | 'disconnect' + +/** `stop-pending-28445`: decided, but the stop itself lands with #28445. */ +type OwnedSessionTeardownDecision = 'stop' | 'retain' | 'stop-pending-28445' + +const REMOVAL = { + pty: 'stop', + 'structured-chat': 'stop', + 'host-browser': 'stop', + launch: 'stop' +} as const + +/** + * What each entry point does to each kind. A new kind or entry point is a compile error here + * until someone decides. Disconnect is loss of contact, never evidence of exit, so it stops nothing. + */ +export const OWNED_SESSION_TEARDOWN = { + 'worktree-removal': REMOVAL, + 'folder-removal': REMOVAL, + 'project-removal': REMOVAL, + // Sleep keeps tabs, so pages and launch records survive to wake. + sleep: { + pty: 'stop', + 'structured-chat': 'stop-pending-28445', + 'host-browser': 'retain', + launch: 'retain' + }, + disconnect: { + pty: 'retain', + 'structured-chat': 'retain', + 'host-browser': 'retain', + launch: 'retain' + } +} as const satisfies Record< + OwnedSessionTeardownEntryPoint, + Record +> + +export function teardownStops( + entryPoint: OwnedSessionTeardownEntryPoint, + kind: OwnedSessionKind +): boolean { + return OWNED_SESSION_TEARDOWN[entryPoint][kind] === 'stop' +} diff --git a/src/main/runtime/project-removal-structured-sessions.test.ts b/src/main/runtime/project-removal-structured-sessions.test.ts index 1783409cfe42..be2f6219e58b 100644 --- a/src/main/runtime/project-removal-structured-sessions.test.ts +++ b/src/main/runtime/project-removal-structured-sessions.test.ts @@ -152,4 +152,35 @@ describe('project removal and native chat sessions', () => { expect(closed).toEqual(['live-chat']) }) + + it("closes a local project's chats though a paired server's residue row shares its id", async () => { + const localRow = store.getRepo(TEST_REPO_ID)! + const residue: Repo = { ...localRow, executionHostId: 'runtime:env-1' } + const { closed } = installHost([record('local-chat', TEST_WORKTREE_ID)]) + const runtime = createRuntime([localRow, residue]) + + await runtime.removeProject(localRow) + + expect(closed).toEqual(['local-chat']) + }) + + it('still asks every terminal to stop once the removal budget is spent', async () => { + installHost([]) + const runtime = createRuntime([store.getRepo(TEST_REPO_ID)!]) + runtime.registerPty('pty-1', TEST_WORKTREE_ID, null) + runtime.registerPty('pty-2', TEST_WORKTREE_ID, null) + const kill = vi.fn((_ptyId: string) => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + + await runtime.stopTerminalsForProject(store.getRepo(TEST_REPO_ID)!, { + deadline: Date.now() - 1 + }) + + expect(kill.mock.calls.map(([ptyId]) => ptyId).sort()).toEqual(['pty-1', 'pty-2']) + }) }) diff --git a/src/main/runtime/repo-icon-fork-backfill.test.ts b/src/main/runtime/repo-icon-fork-backfill.test.ts index 20a4475b9d8c..20317f65dbb9 100644 --- a/src/main/runtime/repo-icon-fork-backfill.test.ts +++ b/src/main/runtime/repo-icon-fork-backfill.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import * as client from '../github/client' import { OrcaRuntimeService } from './orca-runtime' +import { repoTarget } from '../persistence/repo-host-target-matcher.test-fixture' vi.mock('../github/client', async (importOriginal) => ({ ...(await importOriginal>()), @@ -33,8 +34,8 @@ function makeRepo(overrides: Partial = {}): Repo { } function attachStore(runtime: OrcaRuntimeService, repos: Repo[]) { - const updateRepo = vi.fn((repoId: string, updates: Partial) => { - const index = repos.findIndex((repo) => repo.id === repoId) + const updateRepo = vi.fn((target: Pick, updates: Partial) => { + const index = repos.findIndex((repo) => repo.id === target.id) repos[index] = { ...repos[index], ...updates } }) Object.assign(runtime, { store: { getRepos: () => repos, updateRepo } }) @@ -54,19 +55,15 @@ describe('startup fork-upstream backfill', () => { await (runtime as unknown as BackfillInternals).repositoryForkBackfill.run() - expect(updateRepo).toHaveBeenCalledExactlyOnceWith( - 'repo-1', - { - upstream: { owner: 'upstream-org', repo: 'rocket' }, - repoIcon: { - type: 'image', - src: 'https://github.com/acme.png?size=64', - source: 'github', - label: 'acme/rocket-pro' - } - }, - 'local' - ) + expect(updateRepo).toHaveBeenCalledExactlyOnceWith(repoTarget('repo-1'), { + upstream: { owner: 'upstream-org', repo: 'rocket' }, + repoIcon: { + type: 'image', + src: 'https://github.com/acme.png?size=64', + source: 'github', + label: 'acme/rocket-pro' + } + }) }) it('migrates a same-name fork to the upstream owner avatar', async () => { @@ -87,19 +84,15 @@ describe('startup fork-upstream backfill', () => { await (runtime as unknown as BackfillInternals).repositoryForkBackfill.run() - expect(updateRepo).toHaveBeenCalledExactlyOnceWith( - 'repo-1', - { - upstream: { owner: 'upstream-org', repo: 'rocket' }, - repoIcon: { - type: 'image', - src: 'https://github.com/upstream-org.png?size=64', - source: 'github', - label: 'upstream-org/rocket' - } - }, - 'local' - ) + expect(updateRepo).toHaveBeenCalledExactlyOnceWith(repoTarget('repo-1'), { + upstream: { owner: 'upstream-org', repo: 'rocket' }, + repoIcon: { + type: 'image', + src: 'https://github.com/upstream-org.png?size=64', + source: 'github', + label: 'upstream-org/rocket' + } + }) }) it('skips every row whose files sit on an SSH host', async () => { @@ -145,12 +138,8 @@ describe('startup fork-upstream backfill', () => { resolveSlug({ owner: 'acme', repo: 'rocket-pro' }) await backfill - expect(updateRepo).toHaveBeenCalledExactlyOnceWith( - 'repo-1', - { - upstream: { owner: 'upstream-org', repo: 'rocket' } - }, - 'local' - ) + expect(updateRepo).toHaveBeenCalledExactlyOnceWith(repoTarget('repo-1'), { + upstream: { owner: 'upstream-org', repo: 'rocket' } + }) }) }) diff --git a/src/main/runtime/rpc/methods/desktop-browser-bridge.ts b/src/main/runtime/rpc/methods/desktop-browser-bridge.ts index 0014cc3ef33b..468bab72c5fa 100644 --- a/src/main/runtime/rpc/methods/desktop-browser-bridge.ts +++ b/src/main/runtime/rpc/methods/desktop-browser-bridge.ts @@ -1,4 +1,5 @@ import { + DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY, DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY, DesktopBrowserBridgeAttachParams, DesktopBrowserBridgeResultParams @@ -35,7 +36,13 @@ export const DESKTOP_BROWSER_BRIDGE_RPC_METHODS = [ handler: async (params, context, emit) => { const caller = requireBridgeCaller(context) const { runtime, signal } = context - const handle = getDesktopBrowserBridgeRegistry(runtime).attach({ ...caller, emit }) + const acceptsCancel = + context.clientCapabilities?.includes(DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY) ?? false + const handle = getDesktopBrowserBridgeRegistry(runtime).attach({ + ...caller, + acceptsCancel, + emit + }) let release = (): void => {} const released = new Promise((resolve) => { release = resolve diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index 89cb2eca069a..96ae1ea78660 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -6,6 +6,7 @@ import { FileMutationOpen, FileRename, FileWrite, + FileWriteIfUnchanged, FileWriteBase64, FileWriteBase64Chunk } from '../../../../shared/rpc-contract/files-mutation-params' @@ -46,6 +47,21 @@ export const FILE_MUTATION_METHODS = [ ...sshMutationArguments(params) ) }), + defineMethod({ + name: 'files.writeIfUnchanged', + permission: 'workspace', + params: FileWriteIfUnchanged, + handler: async (params, { runtime }) => + runtime.writeFileExplorerFile( + params.worktree, + params.relativePath, + params.content, + params.expectedSshConnectionGeneration, + params.expectedSshTargetId, + params.expectedExecutionHostId, + params.diskPrecondition + ) + }), defineMethod({ name: 'files.writeBase64', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/files-write-disk-precondition.test.ts b/src/main/runtime/rpc/methods/files-write-disk-precondition.test.ts new file mode 100644 index 000000000000..60f6e1cca81f --- /dev/null +++ b/src/main/runtime/rpc/methods/files-write-disk-precondition.test.ts @@ -0,0 +1,67 @@ +import '../unused-default-rpc-methods.test-fixture' +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 } +} + +describe('files.writeIfUnchanged', () => { + it('forwards a save disk precondition to the runtime writer', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reaches only the two stubbed members. + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: false, diskConflict: 'changed' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const diskPrecondition = { expectedSignature: 'a-b-1', allowMissing: true } + + const response = await dispatcher.dispatch( + makeRequest('files.writeIfUnchanged', { + worktree: 'id:wt-1', + relativePath: 'src/index.ts', + content: 'export {}', + expectedExecutionHostId: 'local', + diskPrecondition + }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith( + 'id:wt-1', + 'src/index.ts', + 'export {}', + undefined, + undefined, + 'local', + diskPrecondition + ) + expect(response).toMatchObject({ ok: true, result: { ok: false, diskConflict: 'changed' } }) + }) + + it('strips an unknown write field the way an older host would', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reaches only the two stubbed members. + const runtime = { + getRuntimeId: () => 'test-runtime', + writeFileExplorerFile: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + + await dispatcher.dispatch( + makeRequest('files.write', { + worktree: 'id:wt-1', + relativePath: 'src/index.ts', + content: 'export {}', + futureField: true + }) + ) + + expect(runtime.writeFileExplorerFile).toHaveBeenCalledWith( + 'id:wt-1', + 'src/index.ts', + 'export {}' + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts index 11de7e9cd4c5..fdda4d30a305 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-observed-identity.test.ts @@ -53,7 +53,7 @@ function createWorld(): ObservedWorld { const server = new AgentHookServer() const observed = new AgentStatusObservedPaneIdentities() server.subscribeEnrichedStatus((entry) => - recordObservedAgentStatusPaneIdentity(observed, entry.paneKey, identity) + recordObservedAgentStatusPaneIdentity(observed, entry, identity) ) const host = { ...identity, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 395ee332be90..9b7da061f2bd 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -39,15 +39,37 @@ describe('orchestration worker launch preferences', () => { }) }) - it('rejects OMP effort until its model options support it', () => { - expect(() => - resolveWorkerLaunchPreferences({ - agent: 'omp', - model: 'google-vertex/claude-haiku-4-5@20251001', - effort: 'low' - }) - ).toThrow('does not support effort low') - }) + it.each(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'auto'])( + 'passes native OMP thinking %s without translating it into Codex effort', + (effort) => { + const model = 'custom/model' + const launch = resolveWorkerLaunchPreferences({ agent: 'omp', model, effort }) + expect(launch.preferences).toEqual({ model, effort }) + expect(resolveAgentSessionOptionLaunch('omp', launch.preferences, [], false).args).toEqual([ + '--model', + model, + '--thinking', + effort + ]) + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model, + effort, + capabilities: [], + serverName: 'older-host' + }) + ).toThrow() + } + ) + + it.each(['ultra', 'turbo', 'config-default', 'inherit'])( + 'rejects non-native OMP thinking %s', + (effort) => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'omp', model: 'custom/model', effort }) + ).toThrow(`OMP does not support thinking ${effort}`) + } + ) it('passes an opaque Claude model and portable effort through the shared catalog', () => { expect( diff --git a/src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts b/src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts index 968e1234d317..a55ebfa8522e 100644 --- a/src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-pane-layout-intent.test.ts @@ -38,7 +38,7 @@ async function dispatchUpdatePaneLayout(params: Record) { return { reply, runtimeCalls: updateMobileSessionPaneLayout.mock.calls } } -describe('session.tabs.updatePaneLayout intent marker', () => { +describe('session.tabs.updatePaneLayout retired intent tag', () => { it.each([['gesture'], ['some-future-intent'], [42], [null]])( 'treats a call carrying intent=%j exactly like one without it', async (intent) => { @@ -51,14 +51,9 @@ describe('session.tabs.updatePaneLayout intent marker', () => { } ) - it('parses an unmarked payload from an older client without adding the field', () => { - expect(UpdatePaneLayout.parse(LAYOUT_PARAMS)).not.toHaveProperty('intent') - }) - - it('reads the gesture marker and degrades any other value to unmarked', () => { - expect(UpdatePaneLayout.parse({ ...LAYOUT_PARAMS, intent: 'gesture' }).intent).toBe('gesture') - for (const intent of ['some-future-intent', 42, null, { kind: 'gesture' }]) { - expect(UpdatePaneLayout.parse({ ...LAYOUT_PARAMS, intent }).intent).toBeUndefined() + it('strips any intent an older client sends', () => { + for (const intent of [undefined, 'gesture', 'some-future-intent', 42, null]) { + expect(UpdatePaneLayout.parse({ ...LAYOUT_PARAMS, intent })).not.toHaveProperty('intent') } }) }) 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 ecbb599de7ff..5a1fd705a46d 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 @@ -1,3 +1,5 @@ +import type { AgentProcessVerdict } from '../../../../shared/agent-process-presence' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import '../unused-default-rpc-methods.test-fixture' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -12,6 +14,7 @@ import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-w import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' @@ -22,6 +25,8 @@ import { claudeAndCodexAgents } from '../../../native-chat/agent-session-wire/st const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' const WORKSPACE = 'workspace-1' +const SOURCE_LEAF = '11111111-1111-4111-8111-111111111111' +const SOURCE_PANE = `tab-source:${SOURCE_LEAF}` const OPERATION = `${Date.now()}-00000000000000000000000000000001` const CLIENT = { clientId: 'device-a', @@ -32,6 +37,14 @@ const CLIENT = { let root: string let host: StructuredAgentSessionHost +// The source terminal is a shell with nothing left running in it. +vi.mock('../../../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getFreshShellForegroundSnapshot: async () => [ + { pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' } + ] +})) + function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, @@ -64,8 +77,10 @@ function createParams(operationId = OPERATION, sessionId = SESSION) { const fields = { worktree: `id:${WORKSPACE}`, agent: 'codex' as const, - resumeFrom: { providerSessionId: THREAD, - cliSource: { workspaceId: WORKSPACE, paneKey: 'source-pane' } } + resumeFrom: { + providerSessionId: THREAD, + cliSource: { workspaceId: WORKSPACE, paneKey: SOURCE_PANE } + } } return { envelope: { @@ -82,7 +97,12 @@ function createParams(operationId = OPERATION, sessionId = SESSION) { } } -async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT, unary = false) { +async function call( + dispatcher: RpcDispatcher, + params: unknown, + client: RpcDispatchStreamingOptions = CLIENT, + unary = false +) { const replies: RpcResponse[] = [] const request: RpcRequest = { id: `request-${replies.length + 1}`, @@ -146,6 +166,7 @@ describe('committed adopting create RPC replay', () => { let selectedHome = originalHome const selectAccountHome = vi.fn(() => selectedHome) + let writerVerdict: AgentProcessVerdict = 'live' const runtime = new OrcaRuntimeService( { getSettings: () => ({ @@ -153,15 +174,39 @@ describe('committed adopting create RPC replay', () => { }) } as never, undefined, - { prepareCodexStructuredLaunch: selectAccountHome } + { + prepareCodexStructuredLaunch: selectAccountHome, + readHookCliWriterObservation: () => ({ + current: () => true, + topology: 'pane-owned', + presence: async () => writerVerdict + }) + } ) const sourceRow = { - paneKey: 'source-pane', worktreeId: WORKSPACE, agentType: 'codex', - connectionId: null, receivedAt: 1, stateStartedAt: 1, state: 'waiting' as const, prompt: '', + paneKey: SOURCE_PANE, + worktreeId: WORKSPACE, + agentType: 'codex', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'waiting' as const, + prompt: '', providerSession: { key: 'session_id' as const, id: THREAD, transcriptPath } } let rows: AgentStatusIpcPayload[] = [sourceRow] - const sourceRows = vi.spyOn(runtime, 'getAgentProviderSessionRows').mockImplementation(() => rows) + runtime.registerPty('source-pty', WORKSPACE, null, { + tabId: 'tab-source', + leafId: SOURCE_LEAF, + incarnationId: 'i1' + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the source census reads only listProcesses. + runtime.setPtyController({ + listProcesses: async () => [{ id: 'source-pty', rootProcessId: 10, incarnationId: 'i1' }] + } as never) + const sourceRows = vi + .spyOn(runtime, 'getAgentProviderSessionRows') + .mockImplementation(() => rows) // The structured surface is settings-gated for every caller, not just mobile; this test // probes durable-identity replay, which only runs once the gate admits the call. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The RPC gate reads only this setting from the test double. @@ -212,13 +257,59 @@ describe('committed adopting create RPC replay', () => { runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) + const historyFields = { + worktree: `id:${WORKSPACE}`, + agent: 'codex' as const, + resumeFrom: { providerSessionId: THREAD } + } + const historyParams = { + ...historyFields, + envelope: { + ...createParams().envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: historyFields + }) + } + } + for (const capabilities of [ + CLIENT.clientCapabilities, + [...CLIENT.clientCapabilities, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY] + ]) { + expect( + await call( + dispatcher, + historyParams, + { ...CLIENT, clientCapabilities: capabilities }, + unary + ) + ).toMatchObject({ + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_conflict', + details: { reason: 'conversationHeldElsewhere' }, + createAttemptRefusedBeforeCommit: true + } + } + }) + expect(store.getRecord(SESSION)).toBeFalsy() + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + } + writerVerdict = 'exited' const params = createParams() - rows = unary ? [] : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] - const route = vi.spyOn(runtime, 'routeClientHostedBrowserRpc').mockImplementationOnce(async () => { - rows = [sourceRow] - return { handled: false } - }) + rows = unary + ? [] + : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] + const route = vi + .spyOn(runtime, 'routeClientHostedBrowserRpc') + .mockImplementationOnce(async () => { + rows = [sourceRow] + return { handled: false } + }) expect(await call(dispatcher, params, CLIENT, unary)).toMatchObject({ ok: true, result: { ok: false, refusal: { details: { reason: 'transcriptUnreadable' } } } @@ -284,21 +375,26 @@ describe('committed adopting create RPC replay', () => { expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) - expect(await call(dispatcher, createParams(OPERATION, 'another-chat'))).toMatchObject({ + // The switched-out terminal is checked before the owner is named; an unavailable scan refuses. + const another = await call(dispatcher, createParams(OPERATION, 'another-chat')) + expect(another).toMatchObject({ ok: true, - result: { ok: false, refusal: { details: { - reason: 'conversationHeldElsewhere', owner: { sessionId: SESSION, workspaceId: WORKSPACE } - } } } + result: { ok: false, refusal: { details: { reason: 'conversationHeldElsewhere' } } } }) + expect(JSON.stringify(another).includes(`"owner"`)).toBe(unary) + // Only an exact committed replay bypasses an unavailable writer scan. + const freshRefusal = unary + ? { code: 'agent_session_identity_required' } + : { code: 'agent_session_conflict', details: { reason: 'conversationHeldElsewhere' } } const otherOperation = createParams(`${Date.now()}-00000000000000000000000000000002`) expect(await call(dispatcher, otherOperation)).toMatchObject({ ok: true, - result: { ok: false, refusal: { code: 'agent_session_identity_required' } } + result: { ok: false, refusal: freshRefusal } }) expect(await call(dispatcher, params, { ...CLIENT, clientId: 'device-b' })).toMatchObject({ ok: true, - result: { ok: false, refusal: { code: 'agent_session_identity_required' } } + result: { ok: false, refusal: freshRefusal } }) expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index aa5169a26079..1995a86ca27f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -3,14 +3,23 @@ // // Additive and negotiated: a client calls it only once the host advertises // `agent-session.structured.registered-agents.v1`; without it, a client knows Claude and Codex only. +// +// `agentSession.subscribeAvailability` โ€” this host's health snapshot of its installed agents, as +// complete replacements from the runtime's one shared resource. Negotiated separately. import type { AgentSessionAgentsResult } from '../../../../shared/agent-session-registered-agents' import { AGENT_SESSION_AGENTS_METHOD } from '../../../../shared/agent-session-registered-agents' +import { + AGENT_SESSION_AVAILABILITY_METHOD, + type AgentSessionAvailabilityFrame +} from '../../../../shared/agent-session-agent-availability' import { STRUCTURED_AGENT_RUNTIME_METADATA } from '../../structured-agent-runtime-metadata' -import { defineMethod } from '../core' +import { defineMethod, defineStreamingMethod } from '../core' import { requireStructuredCapability } from './structured-agent-session-gate' -import { AgentsParams } from './structured-agent-session-schemas' +import { AgentsParams, AvailabilityParams } from './structured-agent-session-schemas' import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' +import { bindStructuredAgentSessionStream } from './structured-agent-session-status-stream' +import { structuredAgentSessionAvailabilitySubscriptionId } from './structured-agent-session-subscription-id' export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ @@ -31,5 +40,31 @@ export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ })) } } + }), + defineStreamingMethod({ + name: AGENT_SESSION_AVAILABILITY_METHOD, + permission: 'workspace', + params: AvailabilityParams, + handler: async (_params, ctx, emit) => { + requireStructuredCapability(ctx) + const subscriptionId = structuredAgentSessionAvailabilitySubscriptionId(ctx) + let release = (): void => {} + const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => release()) + if (stream.isClosed()) { + return + } + release = ctx.runtime.subscribeStructuredAgentAvailability((agents) => { + const frame: AgentSessionAvailabilityFrame = { + type: 'availability', + // The audience of `agentSession.agents`: no client hears of an agent it cannot open. + agents: agents.filter(({ agent }) => clientReadsStructuredSessionAgent(ctx, agent)) + } + emit(frame) + }) + // A replayed snapshot can close the transport before `release` was assigned. + if (stream.isClosed()) { + release() + } + } }) -] +] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 12813a8f66ea..f0e68d1ca2f1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -16,6 +16,11 @@ export const CLEANUP_METHODS = [ params: { sessionId: SESSION }, hostCall: 'close' }, + { + method: 'agentSession.sleepWorkspace', + params: { workspaceId: 'workspace-1' }, + hostCall: 'sleepWorkspace' + }, { method: 'agentSession.cancel', params: { envelope: envelope(), turnId: 'turn-1' }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 5d42665d0f06..739d470cc04d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -176,6 +176,7 @@ export function hostStub(): StructuredAgentSessionHost { waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [SESSION] })), revealSession: vi.fn(async () => ({ sessionId: SESSION, workspaceId: 'workspace-1', @@ -324,6 +325,4 @@ export function installStructuredHostStub(): void { setStructuredAgentSessionHost(hostStub()) } -export function clearStructuredHostStub(): void { - setStructuredAgentSessionHost(null) -} +export const clearStructuredHostStub = (): void => setStructuredAgentSessionHost(null) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 76ce9c1b8249..70c26c65b817 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -6,6 +6,7 @@ export { AcknowledgeAttentionParams, AgentsParams, AttachParams, + AvailabilityParams, CancelParams, ConversationCommandParams, CreateIntentParams, @@ -26,6 +27,7 @@ export { RestartResumeParams, RewindParams, SendParams, + SleepWorkspaceParams, SessionId, SetOptionParams, SubscribeParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts index 6167e0588b5d..2deb56364420 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-subscription-id.ts @@ -32,3 +32,8 @@ export function structuredAgentSessionStatusSubscriptionId(ctx: RpcContext): str export function structuredAgentSessionTurnCompletionSubscriptionId(ctx: RpcContext): string { return withFrameId(ctx, `${SUBSCRIPTION_PREFIX}.turn-completion:${ctx.connectionId ?? 'local'}`) } + +/** The availability feed, per connection like the status feed. */ +export function structuredAgentSessionAvailabilitySubscriptionId(ctx: RpcContext): string { + return withFrameId(ctx, `${SUBSCRIPTION_PREFIX}.availability:${ctx.connectionId ?? 'local'}`) +} 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 ae089150db33..535817b6a63f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(37) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(38) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -336,7 +336,7 @@ describe('capability gating', () => { } ) - it.each(CLEANUP_METHODS)( + it.each(CLEANUP_METHODS.filter(({ method }) => method !== 'agentSession.sleepWorkspace'))( 'does not install a host for cleanup-only method $method', async ({ method, params }) => { const ensureHost = vi.fn() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index cef46e353a75..1fe96d81d0b9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -62,10 +62,14 @@ import { OptionsParams, RewindParams, SendParams, + SleepWorkspaceParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredSessionNeverStarted } from '../../agent-session-record-founding' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { AGENT_SESSION_SNAPSHOT_CATCH_UP_CLIENT_CAPABILITY } from '../../../../shared/protocol-version' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { @@ -163,6 +167,52 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), + defineMethod({ + name: 'agentSession.sleepWorkspace', + permission: 'workspace', + params: SleepWorkspaceParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + const installed = getStructuredAgentSessionHost() + if (installed) { + return installed.sleepWorkspace(params.workspaceId) + } + let store: Awaited> + try { + store = await ctx.runtime.openAgentSessionRecordStore() + } catch (error) { + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + console.warn('[agent-session] workspace sleep record inventory unavailable', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + try { + const records = store + .listRecords() + .filter( + (record) => + record.location.workspaceId === params.workspaceId && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + const stoppedSessionIds = records + .filter(structuredSessionNeverStarted) + .map((record) => record.sessionId) + const failures = records + .filter((record) => !structuredSessionNeverStarted(record)) + .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } + } catch (error) { + console.warn('[agent-session] workspace sleep record inventory failed', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + } + }), ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', diff --git a/src/main/runtime/runtime-durable-store-fixture.ts b/src/main/runtime/runtime-durable-store-fixture.ts index c566d35cc9b3..c65c3a86a828 100644 --- a/src/main/runtime/runtime-durable-store-fixture.ts +++ b/src/main/runtime/runtime-durable-store-fixture.ts @@ -7,7 +7,10 @@ import { type PaneLayoutChange, type TerminalPaneAdmission } from '../persistence/terminal-topology/terminal-pane-admission' -import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { + CommandOf, + LayoutRefusalCode +} from '../../shared/workspace-layout/workspace-layout-command-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { clearReplacedPaneBinding } from '../persistence/loading-store/replaced-pane-binding' @@ -102,7 +105,17 @@ export function withRuntimePaneAdmission< return Object.assign(store, { admitTerminalPane: async (admission: TerminalPaneAdmission, hostId?: string) => commit(hostId, (session) => admitPaneToSession(resolveHostId(hostId), session, admission)), - withdrawTerminalPane: async (pane: CommandOf<'closePane'>, hostId?: string) => - commit(hostId, (session) => withdrawPaneFromSession(resolveHostId(hostId), session, pane)) + withdrawTerminalPane: async ( + pane: CommandOf<'closePane'>, + hostId?: string, + stillStarting?: () => boolean + ): Promise => + commit(hostId, (session) => + stillStarting && session.terminalLayoutsByTabId?.[pane.tabId]?.ptyIdsByLeafId?.[pane.leafId] + ? { value: 'bound' as const, session: null } + : stillStarting?.() + ? { value: 'starting' as const, session: null } + : withdrawPaneFromSession(resolveHostId(hostId), session, pane) + ) }) } diff --git a/src/main/runtime/runtime-pane-admission.test.ts b/src/main/runtime/runtime-pane-admission.test.ts index ad6fe476afa3..662be255e886 100644 --- a/src/main/runtime/runtime-pane-admission.test.ts +++ b/src/main/runtime/runtime-pane-admission.test.ts @@ -49,6 +49,7 @@ describe('startInAdmittedPane', () => { ).rejects.toThrow('mount timed out') expect(store.withdrawTerminalPane).toHaveBeenCalledWith( { type: 'closePane', workspace: 'wt-1', tabId: 'tab-1', leafId: 'leaf-new' }, + undefined, undefined ) }) diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index d358e540fdd5..1bf3d6ba9a40 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -3,6 +3,7 @@ import type { TerminalPaneAdmission, TerminalPaneAdmissionOutcome } from '../persistence/terminal-topology/terminal-pane-admission' +import type { LayoutRefusalCode } from '../../shared/workspace-layout/workspace-layout-command-types' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeStore } from './runtime-store-contract' @@ -66,17 +67,24 @@ export async function withdrawRuntimePane( await withdrawAdmittedPane(store, admissionFor(args), hostIdOf(args)) } -/** Never the user's close: main stops no launch for it, unlike a window's closePane. */ -async function withdrawAdmittedPane( +/** + * Never the user's close: main stops no launch for it, unlike a window's closePane. With + * `stillStarting`, a pane whose terminal bound or is starting stays ('bound' / 'starting'). + */ +export async function withdrawAdmittedPane( store: RuntimeStore | null | undefined, admission: TerminalPaneAdmission, - hostId: string | undefined -): Promise { + hostId: string | undefined, + stillStarting?: () => boolean +): Promise { const leafId = admission.type === 'createTerminalTab' ? admission.leafId : admission.newLeafId - await store?.withdrawTerminalPane?.( - { type: 'closePane', workspace: admission.workspace, tabId: admission.tabId, leafId }, - hostId - ) + const pane = { + type: 'closePane' as const, + workspace: admission.workspace, + tabId: admission.tabId, + leafId + } + return store?.withdrawTerminalPane?.(pane, hostId, stillStarting) } /** Writes a pane the runtime asks for into main's copy; what fills it is the caller's. */ diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index d2ecb757276e..7e03490f037e 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -29,7 +29,7 @@ function makeController(): { const store = { getProjects: () => projectHostSetupProjectionFromRepos([repo]).projects, getProjectHostSetups: () => [], - updateRepo: (_id: string, updates: Record) => ({ ...repo, ...updates }) + updateRepo: (_target: unknown, updates: Record) => ({ ...repo, ...updates }) } const addRepo = vi.fn().mockResolvedValue(repo) const cloneRepo = vi.fn().mockResolvedValue(repo) diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index 548f4636fd0c..ecb78c138da2 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -21,6 +21,7 @@ import { } from '../../shared/execution-host' import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' +import { assertImportedFolderRemoteMatchesProject } from '../../shared/imported-folder-project-identity' import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache' import { SshHostNotServedError, @@ -31,6 +32,7 @@ import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { updateProjectHostSetupWithProvenKind } from '../folder-repo-git-conversion' import type { RuntimeStore } from './runtime-store-contract' import { readAllWorktreeMetaForRepo } from '../persistence/host-qualified-worktree-meta' +import { readCurrentGitRemoteIdentity } from '../repo-git-remote-identity' import { countRepoWorkspacesWithMetadata, describeProjectRemovalRefusal @@ -40,6 +42,10 @@ import { assertWslMoveHostSetupWritable, assertWslMoveReposWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { + findProjectHostSetup, + type ProjectHostSetupTarget +} from '../../shared/project-host-setup-target' type RuntimeProjectHostSetupDependencies = { getStore: () => RuntimeStore | null @@ -124,7 +130,10 @@ export class RuntimeProjectHostSetupController { throw sshTargetNotServedError(sshTargetId) } const repo = await this.deps.addRepo(args.path, kind, args.hostId) - return this.completeSetup(args, repo, !knownRepoIds.has(repo.id)) + const repoWasCreated = !knownRepoIds.has(repo.id) + // Why: a registered row's stored remote can predate a remote the user has since added. + const folderRemote = repoWasCreated ? repo : await readCurrentGitRemoteIdentity(repo) + return this.completeSetup(args, repo, repoWasCreated, folderRemote) } /** @@ -145,7 +154,8 @@ export class RuntimeProjectHostSetupController { return this.completeSetup( { ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' }, repo, - !knownRepoIds.has(repo.id) + !knownRepoIds.has(repo.id), + repo ) } @@ -156,7 +166,7 @@ export class RuntimeProjectHostSetupController { if (!store || !updateProjectHostSetup) { throw new Error('runtime_unavailable') } - assertWslMoveHostSetupIdWritable(store, args.setupId) + assertWslMoveHostSetupIdWritable(store, args) return this.finishSetupUpdate(store, updateProjectHostSetup, args) } @@ -168,7 +178,7 @@ export class RuntimeProjectHostSetupController { const result = await updateProjectHostSetupWithProvenKind( store, (setupArgs) => { - assertWslMoveHostSetupIdWritable(store, setupArgs.setupId) + assertWslMoveHostSetupIdWritable(store, setupArgs) return updateProjectHostSetup(setupArgs) }, args, @@ -194,9 +204,9 @@ export class RuntimeProjectHostSetupController { if (!store?.deleteProjectHostSetup) { throw new Error('runtime_unavailable') } - assertWslMoveHostSetupIdWritable(store, args.setupId) + assertWslMoveHostSetupIdWritable(store, args) if (args.force === false) { - this.assertSetupRemovalIsUnused(store, args.setupId) + this.assertSetupRemovalIsUnused(store, args) } const result = store.deleteProjectHostSetup(args) if (!result) { @@ -217,8 +227,8 @@ export class RuntimeProjectHostSetupController { } // Why only for an explicit `force: false`: older clients omit it and must keep their delete. - private assertSetupRemovalIsUnused(store: RuntimeStore, setupId: string): void { - const setup = this.listSetups().find((entry) => entry.id === setupId) + private assertSetupRemovalIsUnused(store: RuntimeStore, target: ProjectHostSetupTarget): void { + const setup = findProjectHostSetup(this.listSetups(), target) // Same exact-host match the store's delete uses, so a sibling host's row is never measured. const repo = setup?.repoId ? this.deps @@ -246,10 +256,11 @@ export class RuntimeProjectHostSetupController { private completeSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo, - repoWasCreated: boolean + repoWasCreated: boolean, + folderRemote: Pick ): ProjectHostSetupResult { try { - return this.linkRepo(args, initialRepo) + return this.linkRepo(args, initialRepo, folderRemote) } catch (error) { if (repoWasCreated) { // Why: host-scoped so a rollback never takes a same-id row on another host. @@ -267,7 +278,8 @@ export class RuntimeProjectHostSetupController { private linkRepo( args: ProjectHostSetupExistingFolderArgs, - initialRepo: Repo + initialRepo: Repo, + folderRemote: Pick ): ProjectHostSetupResult { const store = this.deps.getStore() if (!store) { @@ -286,7 +298,8 @@ export class RuntimeProjectHostSetupController { if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) { throw new Error('Imported folder does not match the selected project identity.') } - const updated = store.updateRepo(repo.id, { + assertImportedFolderRemoteMatchesProject(folderRemote, identity) + const updated = store.updateRepo(repo, { upstream: { owner: identity.owner, repo: identity.repo, @@ -300,7 +313,7 @@ export class RuntimeProjectHostSetupController { setup = getProjectHostSetupForRepo(this.listSetups(), repo) } const setupMethod = args.setupMethod ?? 'imported-existing-folder' - const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) + const updated = store.updateRepo(repo, { projectHostSetupMethod: setupMethod }) if (!updated) { throw new Error( `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` diff --git a/src/main/runtime/runtime-remove-project-host-scope.spec.ts b/src/main/runtime/runtime-remove-project-host-scope.spec.ts index fabf93c1be1a..518915220703 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.spec.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.spec.ts @@ -9,7 +9,8 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' import { OrcaRuntimeService } from './orca-runtime' import { findRepoRowForHostScopedWrite } from '../persistence/tracking-repos/repo-update-operations' -import type { ExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { RepoHostTarget } from '../../shared/repo-host-identity' export function registerRuntimeRemoveProjectHostScopeTests(): void { function makeRepos(): Repo[] { @@ -55,8 +56,8 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void { removeWorktreeMeta: vi.fn(), getGitHubCache: () => null, // Same row lookup the real store's host-scoped write uses. - updateRepo: (id: string, updates: Partial, hostId?: ExecutionHostId) => { - const row = findRepoRowForHostScopedWrite(repos, id, hostId) + updateRepo: (target: RepoHostTarget, updates: Partial) => { + const row = findRepoRowForHostScopedWrite(repos, target.id, getRepoExecutionHostId(target)) return row ? Object.assign(row, updates) : null }, removeProjectForHost diff --git a/src/main/runtime/runtime-repository-clone-controller.ts b/src/main/runtime/runtime-repository-clone-controller.ts index d56b06151789..7323aad06b01 100644 --- a/src/main/runtime/runtime-repository-clone-controller.ts +++ b/src/main/runtime/runtime-repository-clone-controller.ts @@ -166,7 +166,7 @@ export class RuntimeRepositoryCloneController { isFolderRepo(current) && runtimeRepoMatchesExecutionHost(current, executionHostId) ) { - const updated = store.updateRepo(current.id, { kind: 'git', ...conversion }) + const updated = store.updateRepo(current, { kind: 'git', ...conversion }) if (updated) { await prepareLocalWorktreeRootForRepo(store, updated) invalidateAuthorizedRootsCache() diff --git a/src/main/runtime/runtime-repository-fork-backfill.ts b/src/main/runtime/runtime-repository-fork-backfill.ts index 4bbee92e6400..19548f0614cb 100644 --- a/src/main/runtime/runtime-repository-fork-backfill.ts +++ b/src/main/runtime/runtime-repository-fork-backfill.ts @@ -74,7 +74,7 @@ export class RuntimeRepositoryForkBackfill { ) { updates.repoIcon = repoIcon } - store.updateRepo(repo.id, updates, hostId) + store.updateRepo(current, updates) changed = true } if (changed) { diff --git a/src/main/runtime/runtime-repository-registration-controller.test.ts b/src/main/runtime/runtime-repository-registration-controller.test.ts index 87d0bc71a08f..60afec735551 100644 --- a/src/main/runtime/runtime-repository-registration-controller.test.ts +++ b/src/main/runtime/runtime-repository-registration-controller.test.ts @@ -1,8 +1,9 @@ import { runProcessSync } from '@orca/process-host' -import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Repo } from '../../shared/repo-types' import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller' import type { RuntimeStore } from './runtime-store-contract' @@ -138,3 +139,69 @@ describe('RuntimeRepositoryRegistrationController.add through a symlink alias', expect(added.id).not.toBe('ssh-row') }) }) + +describe('RuntimeRepositoryRegistrationController.add stores the checkout git lists', () => { + let root: string + + beforeEach(async () => { + // Why realpath: macOS tmpdir is itself a symlink, and the expectations name git's resolved root. + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-runtime-add-root-'))) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('stores the real toplevel when a git project is added through a symlink alias', async () => { + const repoPath = join(root, 'repo') + await mkdir(repoPath) + gitInit(repoPath) + await symlink(repoPath, join(root, 'repo-link'), dirLinkType) + const { controller } = createController() + + const added = await controller.add(join(root, 'repo-link'), 'git') + + expect(normalizeRuntimePathForComparison(added.path)).toBe( + normalizeRuntimePathForComparison(repoPath) + ) + expect(added.displayName).toBe('repo') + }) + + it('stores the toplevel when a subdirectory of a git checkout is added', async () => { + const repoPath = join(root, 'mono') + await mkdir(join(repoPath, 'pkg', 'web'), { recursive: true }) + gitInit(repoPath) + const { controller } = createController() + + const added = await controller.add(join(repoPath, 'pkg', 'web'), 'git') + + expect(normalizeRuntimePathForComparison(added.path)).toBe( + normalizeRuntimePathForComparison(repoPath) + ) + }) + + it.skipIf(process.platform === 'win32')( + 'keeps a literal backslash in a POSIX checkout name', + async () => { + const repoPath = join(root, 'repo\\name') + await mkdir(repoPath) + gitInit(repoPath) + const { controller } = createController() + + const added = await controller.add(repoPath, 'git') + + expect(added.path).toBe(repoPath) + } + ) + + it('keeps the typed path for a folder workspace reached through an alias', async () => { + const folderPath = join(root, 'notes') + await mkdir(folderPath) + await symlink(folderPath, join(root, 'notes-link'), dirLinkType) + const { controller } = createController() + + const added = await controller.add(join(root, 'notes-link'), 'folder') + + expect(added.path).toBe(join(root, 'notes-link')) + }) +}) diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index d7a0cff081ba..633ec3648d7c 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -47,18 +47,23 @@ export class RuntimeRepositoryRegistrationController { if (gitInfo && !gitInfo.isRepo) { throw new Error(`Not a valid git repository: ${path}`) } + // Why: git lists the main checkout at its resolved toplevel; storing a symlink alias or a + // subdirectory instead hides that checkout as external, so the project shows no workspaces. + const repoPath = gitInfo?.rootPath ?? path const sameHostRepos = store .getRepos() .filter((repo) => runtimeRepoMatchesExecutionHost(repo, executionHostId)) // Why: git's toplevel is already resolved through symlinks, so an alias of a tracked checkout finds it. const existing = - sameHostRepos.find((repo) => runtimePathsEqual(repo.path, path)) ?? + sameHostRepos.find( + (repo) => runtimePathsEqual(repo.path, path) || runtimePathsEqual(repo.path, repoPath) + ) ?? (await findProjectAtSameRealPath( // Why: SSH rows name paths on another machine; resolving them here could match an unrelated local dir. sameHostRepos.filter( (repo) => parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'ssh' ), - gitInfo?.rootPath ?? path + repoPath )) if (existing) { if ( @@ -66,7 +71,7 @@ export class RuntimeRepositoryRegistrationController { parseExecutionHostId(executionHostId)?.kind === 'runtime' ) { const adopted = - store.updateRepo(existing.id, { executionHostId }) ?? + store.updateRepo(existing, { executionHostId }) ?? ({ ...existing, executionHostId } as Repo) this.invalidate(existing.id) return adopted @@ -77,14 +82,14 @@ export class RuntimeRepositoryRegistrationController { // validated and will read `path` in this process. A `runtime:` stamp is how a paired client // addresses the row, not a second machine holding the files. const detected = await detectRepoIconAndUpstream({ - repoPath: path, + repoPath, kind, executionHostId: LOCAL_EXECUTION_HOST_ID }) const repo: Repo = { id: randomUUID(), - path, - displayName: displayName?.trim() || getRepoName(path), + path: repoPath, + displayName: displayName?.trim() || getRepoName(repoPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, ...(executionHostId != null ? { executionHostId } : {}), ...detected, diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index c742b36ee7ad..014f9482dc7c 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -7,6 +7,7 @@ import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard' +import { teardownStops } from './owned-session-teardown-policy' const PROJECT_TERMINAL_STOP_BUDGET_MS = 10_000 @@ -73,11 +74,7 @@ export class RuntimeRepositorySettingsController { if (isFolderRepo(repo)) { throw new Error('Folder mode does not support base refs.') } - const updated = store.updateRepo( - repo.id, - { worktreeBaseRef: baseRef }, - getRepoExecutionHostId(repo) - ) + const updated = store.updateRepo(repo, { worktreeBaseRef: baseRef }) if (!updated) { throw new Error('repo_not_found') } @@ -105,12 +102,7 @@ export class RuntimeRepositorySettingsController { } const write = (proven: Pick): Repo | null => { assertWslMoveRepoWritable(store, repo) - // Why the host: a same-id sibling on another host would take an id-only write. - return store.updateRepo( - repo.id, - { ...sanitizedUpdates, ...proven }, - getRepoExecutionHostId(repo) - ) + return store.updateRepo(repo, { ...sanitizedUpdates, ...proven }) } const updated = sanitizedUpdates.kind === 'git' && isFolderRepo(repo) @@ -131,12 +123,13 @@ export class RuntimeRepositorySettingsController { return updated } - async remove(repoSelector: string): Promise<{ removed: true }> { + /** `repo.rm` names a selector; this desktop's own removal names the exact host row. */ + async remove(target: string | Repo): Promise<{ removed: true }> { const store = this.deps.getStore() if (!store?.removeProjectForHost) { throw new Error('runtime_unavailable') } - const repo = await this.deps.resolveRepo(repoSelector) + const repo = typeof target === 'string' ? await this.deps.resolveRepo(target) : target const hostId = getRepoExecutionHostId(repo) assertWslMoveRepoWritable(store, repo) // Why here: the host owns these shells; a client only stops the workspaces it has loaded, @@ -156,7 +149,7 @@ export class RuntimeRepositorySettingsController { // Why again: a terminal spawned while the first stop ran is past that sweep's snapshot, and // nothing can spawn into the project once it is gone. Chats only now: retiring their tabs is // irreversible, and the WSL fence above can still refuse the removal. - await stopProjectTerminals(true) + await stopProjectTerminals(teardownStops('project-removal', 'structured-chat')) this.deps.forgetTerminalTopology(repo.id) this.deps.invalidateResolvedWorktrees() this.deps.invalidateWorktreeScan(repo.id) diff --git a/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts b/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts index 34619a68c88b..d075672984f4 100644 --- a/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts +++ b/src/main/runtime/runtime-rpc-worktree-store-fixtures.ts @@ -13,7 +13,7 @@ export const makeStore = (overrides?: { isUnread?: boolean }) => ({ } ], addRepo: () => {}, - updateRepo: (id: string, updates: Record) => + updateRepo: ({ id }: { id: string }, updates: Record) => ({ ...makeStore(overrides).getRepo(id), ...updates diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index d25544d82a5d..15cc15a574c2 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -263,6 +263,13 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { if (socket.clientCapabilities.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY)) { this.runtime.noteAgentLaunchClientReady?.() } + // Why once per socket: only phones send runtime.clientCapabilities.update. + if (socket.device.scope === 'runtime') { + this.runtime.sidebarFacts?.noteRuntimeClient( + socket.connectionId, + socket.clientCapabilities + ) + } }, onClose: (socket, hasOtherConnections) => { if (!socket) { diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 89cd751347ec..ec81bed136c9 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -14,6 +14,7 @@ import type { PtyProviderBufferSnapshot } from '../providers/pty-provider-contra import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' import type { TerminalTailWaitState } from './terminal-wait-tail-state' import type { TerminalCommandPaint } from './terminal-command-paint' +import type { CliWriterConversation } from './structured-agent-cli-writers' import type { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' @@ -68,6 +69,11 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null agentSessionOwners: AgentSessionOwnerBinding[] + /** Conversations the `incarnationId` execution reported; a later row can't hide them. In memory only. */ + cliConversations?: { + incarnationId: PtyIncarnationId | null + byKey: Map + } foregroundAgent: TerminalAgent | null connected: boolean disconnectedAt: number | null diff --git a/src/main/runtime/session-tabs/structured-session-account-home.ts b/src/main/runtime/session-tabs/structured-session-account-home.ts new file mode 100644 index 000000000000..81c0fd7c4c27 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-account-home.ts @@ -0,0 +1,42 @@ +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { structuredAgentRuntimeMetadata } from '../structured-agent-runtime-metadata' +import { getProfileUserDataPath } from '../../orca-profiles/profile-storage-paths' +import { OrcaRuntimeWithStructuredSessionCliWriterHost } from './structured-session-cli-writer-host' + +export abstract class OrcaRuntimeWithStructuredSessionAccountHome extends OrcaRuntimeWithStructuredSessionCliWriterHost { + /** Where a launch of `agent` finds its account, resolved on this host by the agent's own + * registration; null for an agent this runtime does not register, whose create is refused. */ + protected structuredAgentAccountHomeResolver( + agent: StructuredAgentId, + worktree: string, + purpose: 'launch' | 'read', + hostLaunchDirectory?: string + ) { + const registration = structuredAgentRuntimeMetadata(agent) + if (!registration) { + return null + } + return async ({ launchEnv, location }) => + registration.resolveAccountHome( + { + launchEnv, + location: location ?? null, + purpose, + workspacePath: + purpose === 'launch' + ? async () => + hostLaunchDirectory ?? + (await this.resolveRuntimeFileTarget(worktree)).worktree.path + : null + }, + { + getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), + prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, + readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, + workspaceTrustSettings: () => this.requireStore().getSettings(), + // The chat host's own state root (ensureStructuredAgentSessionHost). + stateDirectory: getProfileUserDataPath() + } + ) + } +} diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts new file mode 100644 index 000000000000..9cc4e05c4532 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -0,0 +1,149 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' +import { getProviderForPty } from '../../ipc/pty/provider/registry' +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection' +import { inspectionShowsShellAlone } from '../run-terminal-shell-alone' +import { + assertCliConversationWritersExited, + posixCliWriterShellAlone, + type CliWriterPane, + type CliWriterTerminal +} from '../structured-agent-cli-writers' +import type { EnrichedAgentHookEventPayload } from '../../agent-hooks/server/server-types' +import { + OPEN_CODE_WRITER_TOPOLOGY, + cliWriterTopology +} from '../../agent-hooks/server/cli-writer-observation' + +export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { + private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null { + const pty = ptyId ? this.ptysById.get(ptyId) : undefined + return pty?.connected && !pty.connectionId && !pty.isWsl && pty.incarnationId + ? { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + identity: `${pty.ptyId}:${pty.incarnationId}` + } + : null + } + + protected cliWriterTerminal(coordinates: { + paneKey: string + worktreeId?: string + }): CliWriterTerminal | null { + const record = this.getPtyRecordForPaneKey(coordinates.paneKey) + if (!record || this.getPtyLivenessVerdict(record.ptyId)?.status === 'exited') { + return null + } + try { + const { ptyId } = this.resolveTerminalPane(coordinates.paneKey, coordinates.worktreeId ?? '') + return { pane: this.cliWriterPtyPane(ptyId) } + } catch { + return { pane: null } + } + } + + /** On the PTY record, so a conversation link lives exactly as long as the execution it names. */ + recordCliWriterConversation(event: EnrichedAgentHookEventPayload): void { + const agent = + event.connectionId === null && !event.structuredHost ? event.payload.agentType : undefined + const binding = event.providerSession + const pty = agent && binding ? this.getPtyRecordForPaneKey(event.paneKey) : null + if (!agent || !binding || !pty) { + return + } + if (pty.cliConversations?.incarnationId !== pty.incarnationId) { + pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() } + } + const key = `${agent}\0${binding.id}` + const previous = pty.cliConversations.byKey.get(key) + pty.cliConversations.byKey.set(key, { + agent, + id: binding.id, + // An unmarked binding copy can't vouch either way, so it never revokes an earlier proof. + paneOwned: + cliWriterTopology(event) === 'pane-owned' || + (!(OPEN_CODE_WRITER_TOPOLOGY in binding) && previous?.paneOwned === true), + process: event.agentPresence?.process ?? previous?.process + }) + } + + async assertStructuredAdoptionTerminalWritersExited( + agent: StructuredAgentId, + providerSessionId: string, + source?: { workspaceId: string; paneKey: string } + ): Promise { + await assertCliConversationWritersExited( + { + rows: () => this.getAgentProviderSessionRows(), + saved: () => + Object.values( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) + ?.sleepingAgentSessionsByPaneKey ?? {} + ), + observation: (row) => this.readHookCliWriterObservationFn?.(row) ?? null, + terminal: (coordinates) => this.cliWriterTerminal(coordinates), + links: () => + [...this.ptysById.values()].flatMap((pty) => { + const conversations = pty.cliConversations + if ( + !conversations || + conversations.incarnationId !== pty.incarnationId || + this.getPtyLivenessVerdict(pty.ptyId)?.status === 'exited' + ) { + return [] + } + const terminal = () => ({ + pane: this.ptysById.get(pty.ptyId) === pty ? this.cliWriterPtyPane(pty.ptyId) : null + }) + return [...conversations.byKey.values()].map((conversation) => ({ + ...conversation, + worktreeId: pty.worktreeId, + terminal + })) + }), + sleeping: (workspaceId) => + [...this.terminalSleepStateByWorktreeId.values()].some( + (state) => + state.worktreeId === workspaceId && + (state.phase === 'sleeping' || + state.phase === 'partial' || + state.phase === 'stopping') + ), + shellAlone: async (pane) => { + if (process.platform === 'win32') { + const provider = getProviderForPty(pane.ptyId) + const inspection = await inspectPtyProviderProcess(provider, pane.ptyId, { + expectedIncarnationId: pane.incarnationId, + scanChildProcesses: true, + ignoreDetached: true + }) + const evidence = inspection.foregroundProcessEvidence + return ( + inspectionShowsShellAlone(inspection) && + (provider instanceof LocalPtyProvider || + (evidence?.ptyId === pane.ptyId && + evidence.ptyIncarnationId === pane.incarnationId && + evidence.verdict === 'unverifiable' && + evidence.reason === 'windows_ssh_foreground_unavailable')) + ) + } + const processes = await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) + const entry = processes?.find((candidate) => candidate.id === pane.ptyId) + const pty = this.ptysById.get(pane.ptyId) + return ( + !!entry?.rootProcessId && + !!entry.incarnationId && + entry.incarnationId === pty?.incarnationId && + (await posixCliWriterShellAlone(entry.rootProcessId)) + ) + } + }, + agent, + providerSessionId, + source && { paneKey: source.paneKey, worktreeId: source.workspaceId } + ) + } +} diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 112c0270fc90..b71a8c6003dd 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -4,7 +4,7 @@ import { } from '../../native-chat/structured-agent-cli-source' import type { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce as RestoreStructuredAgentSessionTabsOnceMethods } from './structured-session-tab-restoration' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' +import { OrcaRuntimeWithStructuredSessionAccountHome } from './structured-session-account-home' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { resolveCommittedStructuredAgentSessionAdoptionIntent, @@ -34,7 +34,7 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { +export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithStructuredSessionAccountHome { protected abstract restoreStructuredAgentSessionTabsOnce( ...args: Parameters< RestoreStructuredAgentSessionTabsOnceMethods['restoreStructuredAgentSessionTabsOnce'] @@ -55,42 +55,6 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte }) } - /** Where a launch of `agent` finds its account, resolved on this host by the agent's own - * registration; null for an agent this runtime does not register, whose create is refused. */ - protected structuredAgentAccountHomeResolver( - agent: StructuredAgentId, - worktree: string, - purpose: 'launch' | 'read', - hostLaunchDirectory?: string - ) { - const registration = structuredAgentRuntimeMetadata(agent) - if (!registration) { - return null - } - return async ({ launchEnv, location }) => - registration.resolveAccountHome( - { - launchEnv, - location: location ?? null, - purpose, - workspacePath: - purpose === 'launch' - ? async () => - hostLaunchDirectory ?? - (await this.resolveRuntimeFileTarget(worktree)).worktree.path - : null - }, - { - getClaudeConfigDirectory: (target) => this.accounts.getClaudeConfigDirectory(target), - prepareCodexLaunchHome: this.prepareCodexStructuredLaunchFn, - readCodexLaunchHome: this.resolveCodexStructuredLaunchHomeFn, - workspaceTrustSettings: () => this.requireStore().getSettings(), - // The chat host's own state root (ensureStructuredAgentSessionHost). - stateDirectory: getProfileUserDataPath() - } - ) - } - /** The definition this runtime registers for `agent`: what its account home pins. Read from the * registration list, so neither create nor a catalog read installs the host to learn it. */ protected requireRegisteredStructuredAgent(agent: StructuredAgentId) { @@ -155,12 +119,24 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte input: StructuredAgentSessionCreateIntentInput ): Promise { if (input.resumeFrom) { - requireUnownedStructuredAgentSessionAdoption({ - host: getStructuredAgentSessionHost(), - agent: input.agent, - providerSessionId: input.resumeFrom.providerSessionId, - selfSessionId: input.envelope.sessionId - }) + const { providerSessionId, cliSource } = input.resumeFrom + try { + requireUnownedStructuredAgentSessionAdoption({ + host: getStructuredAgentSessionHost(), + agent: input.agent, + providerSessionId, + selfSessionId: input.envelope.sessionId + }) + } catch (ownerConflict) { + // A switch out of a live terminal refuses as such: the owning chat's first send would + // otherwise start a second writer. + await this.assertStructuredAdoptionTerminalWritersExited( + input.agent, + providerSessionId, + cliSource + ) + throw ownerConflict + } } const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? @@ -243,6 +219,13 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return committedReplay } const resumeFrom = input.resumeFrom + if (resumeFrom) { + await this.assertStructuredAdoptionTerminalWritersExited( + input.agent, + resumeFrom.providerSessionId, + resumeFrom.cliSource + ) + } const workspaceCwd = resumeFrom ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : '' diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts index e3a217112490..a20b610fd214 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sidebar-temporal.ts @@ -19,7 +19,10 @@ import type { AgentStatusStoreView } from './workspace-activity-facts-producer' import { SortStampsProducer } from './sort-stamps-producer' import { writeUnreadFlag } from './sort-stamp-records' import { WorkspaceViewers } from './workspace-viewers' +import { clientRaisesUnreadFlagItself } from '../../../shared/workspace-unread-raise-capability' +import type { RuntimeCapability } from '../../../shared/protocol-version' import { TerminalUseProducer } from './terminal-use-producer' +import { createKeyedTrailingEdgeCoalescer } from '../keyed-trailing-edge-coalescer' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { resolveAgentWorkspaceExecutionHostId } from '../../agent-hooks/restored-status-dead-terminal-sweep' @@ -41,6 +44,12 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork retentionCandidates: () => this.readSidebarRetentionCandidates() }) readonly workspaceViewers = new WorkspaceViewers(Date.now) + // A streaming agent can flip its title many times a second; each notice makes every client + // re-list the repo, so a burst is announced once. The store is already written. + private readonly sortStampNotices = createKeyedTrailingEdgeCoalescer( + (worktreeId) => this.notifySortStampsChanged(worktreeId), + { flushMs: 50, maxWaitMs: 250 } + ) readonly sortStamps = new SortStampsProducer({ store: () => this.store, isWriteFenced: (worktreeId) => @@ -48,7 +57,10 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork subscribeToPtyExit: (ptyId, listener) => this.subscribeToPtyExit(ptyId, listener), onClientEvent: (listener, options) => this.onClientEvent(listener, options), isViewing: (worktreeId, paneKey) => this.workspaceViewers.isViewing(worktreeId, paneKey), - onStampsWritten: (worktreeId) => this.notifySortStampsChanged(worktreeId), + onStampsWritten: (worktreeId) => { + this.invalidateResolvedWorktreeCache() + this.sortStampNotices.schedule(worktreeId) + }, now: Date.now }) readonly terminalUse = new TerminalUseProducer({ @@ -132,6 +144,19 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork .catch((error: unknown) => console.warn('[sidebar] sort tier reconcile failed:', error)) } + /** A paired desktop connected; while one that raises the unread flag itself is here, this host doesn't. */ + noteRuntimeClient(connectionId: string, capabilities: readonly RuntimeCapability[]): void { + if (!clientRaisesUnreadFlagItself(capabilities)) { + return + } + this.workspaceViewers.noteSelfRaisingClient(connectionId) + this.registerSubscriptionCleanup( + `sidebar-self-raising-${connectionId}`, + () => this.workspaceViewers.dropClient(connectionId), + connectionId + ) + } + /** A phone holding the workspace's session open has it on screen: its flag is read and stays down. */ readAndHoldPhoneSession(worktreeId: string): () => void { const hostId = this.workspaceHostId(worktreeId) @@ -140,6 +165,7 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork sshHostId: hostId && parseExecutionHostId(hostId)?.kind === 'ssh' ? hostId : null } if (writeUnreadFlag(this.store, workspace, false)) { + this.sortStampNotices.cancel(worktreeId) this.notifySortStampsChanged(worktreeId) } return this.workspaceViewers.openPhoneSession(worktreeId) @@ -177,6 +203,7 @@ export abstract class OrcaRuntimeWithSidebarTemporal extends OrcaRuntimeWithWork detachActivity() detachStamps() this.sidebarTemporal.dispose() + this.sortStampNotices.dispose() } } diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts index 075ad5c9241d..298cc45fa4ef 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-sort-stamps.test.ts @@ -60,16 +60,21 @@ describe('host sort stamps on a headless runtime', () => { } }) - it('announces a stamp write as a metadata change of the rowโ€™s repo', () => { + it('announces a burst of stamp writes once, as a metadata change of the rowโ€™s repo', () => { vi.useFakeTimers({ now: 55_000 }) try { - const { runtime } = createHeadlessRuntime() - const events: unknown[] = [] + const { runtime, meta } = createHeadlessRuntime() + const events: { type: string }[] = [] runtime.onClientEvent((event) => events.push(event), { consumesTerminalSideEffects: false }) - runtime.onPtyData('pty-1', '\x07', 1) - expect(events).toContainEqual( + runtime.onPtyData('pty-1', '\x1b]0;โ ‚ Working\x07', 1) + runtime.onPtyData('pty-1', '\x1b]0;bash\x07', 2) + runtime.onPtyData('pty-1', '\x1b]0;โ ‚ Working\x07', 3) + expect(meta[WT].smartTier).toBe('working') + expect(events.filter((event) => event.type === 'worktreesChanged')).toEqual([]) + vi.advanceTimersByTime(50) + expect(events.filter((event) => event.type === 'worktreesChanged')).toEqual([ expect.objectContaining({ type: 'worktreesChanged', repoId: 'repo-1' }) - ) + ]) } finally { vi.useRealTimers() } diff --git a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts index b2572baad24f..5042dcbedf3a 100644 --- a/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts +++ b/src/main/runtime/sidebar-inputs/orca-runtime-workspace-unread.test.ts @@ -10,6 +10,15 @@ import { RpcDispatcher } from '../rpc/dispatcher' import type { RpcDispatchStreamingOptions } from '../rpc/dispatcher-stream-options' import { SESSION_TAB_METHODS } from '../rpc/methods/session-tabs' import { SIDEBAR_INPUT_METHODS } from '../rpc/methods/sidebar-inputs' +import { requireSwitchedOn } from './require-switched-on' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities' +import { remoteRuntimeClientCapabilities } from '../../../shared/remote-runtime-client-capabilities' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../shared/workspace-unread-raise-capability' +import { + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, + BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true })) @@ -23,6 +32,23 @@ const PHONE: RpcDispatchStreamingOptions = { clientKind: 'mobile', connectionId: type Flag = { lastActivityAt: number; isUnread: boolean } +/** What each kind of desktop socket sends: the request and subscription sockets, the browser host + * lease and network tunnel (which add only browser capabilities to the shared base), and the CLI. */ +function currentDesktopSockets() { + return { + request: remoteRuntimeClientCapabilities(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES), + lease: remoteRuntimeClientCapabilities([ + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY + ]), + tunnel: remoteRuntimeClientCapabilities([ + BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, + BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY + ]), + cli: remoteRuntimeClientCapabilities() + } +} + function createHeadlessHost() { const meta: Record = { [WT]: { lastActivityAt: 0, isUnread: false } } const sshMeta: Record = { [SSH_WT]: { lastActivityAt: 0, isUnread: false } } @@ -181,4 +207,40 @@ describe('workspace unread flag on a headless host', () => { vi.useRealTimers() } }) + + it('advertises that it raises the flag, so no socket of a current desktop silences it', () => { + const host = createHeadlessHost() + expect(host.runtime.getStatus().capabilities).toContain(WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY) + const facts = requireSwitchedOn(host.runtime.sidebarFacts) + for (const [connectionId, capabilities] of Object.entries(currentDesktopSockets())) { + facts.noteRuntimeClient(connectionId, capabilities) + } + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + }) + + it('leaves raising to an older desktop while it is connected, and still clears on the phone', async () => { + const host = createHeadlessHost() + // Either its request socket or its browser host lease hosts client pages without the capability. + const older = (capabilities: readonly string[]) => + capabilities.filter((capability) => capability !== WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY) + const facts = requireSwitchedOn(host.runtime.sidebarFacts) + facts.noteRuntimeClient('desktop-old', older(currentDesktopSockets().request)) + facts.noteRuntimeClient('desktop-old-lease', older(currentDesktopSockets().lease)) + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(false) + + // The older desktop raised it by its own rule; the phone opening it still clears it here. + host.meta[WT].isUnread = true + await host.openOnPhone(WT) + expect(host.meta[WT].isUnread).toBe(false) + host.runtime.cleanupSubscriptionsForConnection('phone-1') + + host.runtime.cleanupSubscriptionsForConnection('desktop-old') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(false) + host.runtime.cleanupSubscriptionsForConnection('desktop-old-lease') + host.finishTurn() + expect(host.meta[WT].isUnread).toBe(true) + }) }) diff --git a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts index c65cbfd8dbb8..0da92e7d763c 100644 --- a/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts +++ b/src/main/runtime/sidebar-inputs/paired-server-rows-producer.test.ts @@ -119,7 +119,9 @@ describe('PairedServerRowsProducer', () => { it('populates from a server in contact once its change stream is ready', async () => { const { hosts, call, watches } = fakeHosts([{ id: 'box', live: true }]) - call.mockResolvedValue(psReply([row('repo::/a', { workspaceKind: 'git' })])) + call.mockResolvedValue( + psReply([row('repo::/a', { workspaceKind: 'git', liveTerminalCount: 1, status: 'active' })]) + ) const producer = new PairedServerRowsProducer({ hasDemand: () => true, hostLabelOverrides: () => new Map() @@ -158,7 +160,9 @@ describe('PairedServerRowsProducer', () => { isPinned: false, isMainWorktree: false, lineage: null, - workspaceStatus: 'in-progress' + workspaceStatus: 'in-progress', + // The server's own terminals and agents. + activity: { terminal: 'live', agent: null } } ] }) @@ -405,7 +409,8 @@ describe('PairedServerRowsProducer', () => { isPinned: false, isMainWorktree: false, lineage: null, - workspaceStatus: 'in-progress' + workspaceStatus: 'in-progress', + activity: { terminal: null, agent: null } } ]) expect(rowsOf(producer, 'runtime:two')?.rows.map((entry) => [entry.key, entry.kind])).toEqual([ diff --git a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts index c98820cda9da..a2d86cc50660 100644 --- a/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts +++ b/src/main/runtime/sidebar-inputs/sidebar-inputs-feed-domains.ts @@ -14,6 +14,8 @@ import type { RepoIconDataUrls } from './repo-icon-data-urls' import type { SidebarInputsFeedDomain } from './sidebar-inputs-feed' import { readDesktopSidebarFacts } from './desktop-sidebar-facts' import { buildPairedServerReviewFacts } from '../../../shared/sidebar-list/workspace-review-facts' +import { buildPairedServerActivityFacts } from '../../../shared/sidebar-list/paired-server-activity-facts' +import { parseExecutionHostId } from '../../../shared/execution-host' /** This host's store and connection registries, read the way sidebarInputs.desktopFacts reads them. */ export function readRuntimeDesktopSidebarFacts(store: RuntimeStore): DesktopSidebarFacts { @@ -70,8 +72,27 @@ export function sidebarInputsFeedDomains( return [ { name: 'activity', - read: () => facts.getWorkspaceActivityFacts().facts, - watch: (changed) => facts.workspaceActivityFacts.revision.onChange(changed) + read: () => ({ + // A paired server's rows too; this host's own rows win an id both list. + ...buildPairedServerActivityFacts( + pairedServerRows.snapshot().servers, + (hostId) => sources.store()?.getWorkspaceSession?.(hostId) ?? null + ), + ...facts.getWorkspaceActivityFacts().facts + }), + watch: (changed) => { + const detaches = [ + facts.workspaceActivityFacts.revision.onChange(changed), + pairedServerRows.revision.onChange(changed), + // This desktop's saved session for a paired server holds its browser and chat tabs there. + sources.store()?.onWorkspaceSessionWritten?.((hostId) => { + if (hostId === null || parseExecutionHostId(hostId)?.kind === 'runtime') { + changed() + } + }) + ] + return () => detaches.forEach((detach) => detach?.()) + } }, { name: 'temporal', @@ -107,7 +128,10 @@ export function sidebarInputsFeedDomains( Object.fromEntries( pairedServerRows .snapshot() - .servers.flatMap(({ rows }) => rows.map((row) => [row.key, row])) + // Activity goes out in its own domain, so a terminal count does not resend the row. + .servers.flatMap(({ rows }) => + rows.map(({ activity: _activity, ...row }) => [row.key, row]) + ) ), watch: (changed) => pairedServerRows.revision.onChange(changed) }, diff --git a/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts b/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts index f8776f282d00..c30fcbd3db8d 100644 --- a/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts +++ b/src/main/runtime/sidebar-inputs/workspace-viewers.test.ts @@ -50,4 +50,13 @@ describe('WorkspaceViewers', () => { second() expect(v.isViewing(WT, PANE)).toBe(false) }) + + it('counts everything as seen while an older desktop that raises the flag itself is connected', () => { + const { viewers: v } = viewers() + v.noteSelfRaisingClient('old-desktop') + expect(v.isViewing(WT, PANE)).toBe(true) + expect(v.isViewing('repo-1::/tmp/b', null)).toBe(true) + v.dropClient('old-desktop') + expect(v.isViewing(WT, PANE)).toBe(false) + }) }) diff --git a/src/main/runtime/sidebar-inputs/workspace-viewers.ts b/src/main/runtime/sidebar-inputs/workspace-viewers.ts index 5cc7389355b1..d23883414012 100644 --- a/src/main/runtime/sidebar-inputs/workspace-viewers.ts +++ b/src/main/runtime/sidebar-inputs/workspace-viewers.ts @@ -12,6 +12,7 @@ type ClientView = { workspaceKeys: Set; tabIds: Set; presentUnti export class WorkspaceViewers { private readonly clients = new Map() private readonly phoneSessions = new Map() + private readonly selfRaisingClients = new Set() constructor(private readonly now: () => number) {} @@ -27,8 +28,15 @@ export class WorkspaceViewers { return !known } + /** An older desktop raises the flag itself and reports no view, so while it is connected the + * host leaves raising to it. The caller drops it with {@link dropClient} when it closes. */ + noteSelfRaisingClient(clientKey: string): void { + this.selfRaisingClients.add(clientKey) + } + dropClient(clientKey: string): void { this.clients.delete(clientKey) + this.selfRaisingClients.delete(clientKey) } /** A phone's open session on the workspace, until the returned release. */ @@ -53,7 +61,7 @@ export class WorkspaceViewers { /** As the window's own rule: a pane is seen when its tab is on screen, else the workspace. */ isViewing(worktreeId: string, paneKey: string | null): boolean { const key = worktreeIdentityKey(worktreeId) - if (this.phoneSessions.has(key)) { + if (this.selfRaisingClients.size > 0 || this.phoneSessions.has(key)) { return true } const tabId = paneKey === null ? null : (parsePaneKey(paneKey)?.tabId ?? null) diff --git a/src/main/runtime/structured-agent-availability.test.ts b/src/main/runtime/structured-agent-availability.test.ts new file mode 100644 index 000000000000..68bb274f130a --- /dev/null +++ b/src/main/runtime/structured-agent-availability.test.ts @@ -0,0 +1,120 @@ +import { homedir } from 'node:os' +import { beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/global-settings-types' + +const mocks = vi.hoisted(() => ({ + refreshed: new Set<() => void>(), + observe: vi.fn(), + recapture: vi.fn(async () => {}) +})) +vi.mock('../preflight/workspace-agent-detection', () => ({ + onAgentsRefreshedOnHost: (listener: () => void) => mocks.refreshed.add(listener) +})) +vi.mock('./structured-agent-launch-support', () => ({ + observeStructuredAgentLaunch: mocks.observe +})) +vi.mock('./structured-agent-shell-environment', () => ({ + recaptureHostShellEnvironment: mocks.recapture +})) +// Grok stands for an agent this host cannot supervise (Windows without process start times). +vi.mock('./structured-agent-runtime-metadata', () => ({ + STRUCTURED_AGENT_RUNTIME_METADATA: ['claude', 'grok', 'pi', 'opencode'].map((agent) => ({ + definition: { agent }, + supportsLocation: () => agent !== 'grok' + })) +})) + +import { installStructuredAgentAvailability } from './structured-agent-availability' + +beforeEach(() => { + mocks.refreshed.clear() + mocks.observe.mockReset().mockResolvedValue('supported') + mocks.recapture.mockReset().mockResolvedValue(undefined) +}) + +function install() { + const listeners: ((updates: Partial, settings: GlobalSettings) => void)[] = [] + const store = { + onSettingsChanged: (listener: (typeof listeners)[number]) => { + listeners.push(listener) + return () => {} + } + } + const availability = installStructuredAgentAvailability(store, { + requireStore: () => ({ getSettings: () => getDefaultSettings('/home/test') }) + }) + const change = (updates: Partial): void => + listeners.forEach((listener) => listener(updates, getDefaultSettings('/home/test'))) + return { availability, change } +} + +it('shares a host-home snapshot even if shell recapture fails, retaining it without readers', async () => { + mocks.recapture.mockRejectedValueOnce(new Error('shell capture failed')) + const cwds: string[] = [] + mocks.observe.mockImplementation(async (agent: string, cwd: () => Promise) => { + cwds.push(await cwd()) + if (agent === 'pi') { + throw new Error('Command setting not runnable') + } + return agent === 'opencode' ? 'unsupported' : 'supported' + }) + const { availability } = install() + const first = vi.fn() + const release = availability.subscribe(first) + await vi.waitFor(() => expect(first).toHaveBeenCalledTimes(1)) + release() + expect(first.mock.calls[0][0]).toEqual([ + { agent: 'claude', state: 'available' }, + { agent: 'grok', state: 'refused', reason: 'agent' }, + // A check that could not answer is no verdict. + { agent: 'pi', state: 'unknown' }, + { agent: 'opencode', state: 'refused', reason: 'agent' } + ]) + expect(cwds).toEqual([homedir(), homedir(), homedir()]) + // Kept with no readers: a later one reads it at once, without probing again. + const later = vi.fn() + availability.subscribe(later) + expect(later).toHaveBeenCalledWith(first.mock.calls[0][0]) + expect(mocks.observe).toHaveBeenCalledTimes(3) +}) + +it('replaces the snapshot on a launch setting or Refresh, which first recaptures the shell', async () => { + const { availability, change } = install() + const read = vi.fn() + availability.subscribe(read) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1)) + change({ terminalFontSize: 14 }) + change({ agentCmdOverrides: { pi: '/opt/pi' } }) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) + const capture = Promise.withResolvers() + let version = 'unsupported' + mocks.observe.mockImplementation(async () => version) + mocks.recapture.mockImplementation(() => capture.promise) + mocks.refreshed.forEach((listener) => listener()) + change({ agentDefaultEnv: { pi: { PI_TEST: 'updated' } } }) + await new Promise((resolve) => setImmediate(resolve)) + expect(read).toHaveBeenCalledTimes(2) + expect(mocks.observe).toHaveBeenCalledTimes(6) + version = 'supported' + capture.resolve() + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(3)) + expect(read.mock.lastCall?.[0]).toContainEqual({ agent: 'opencode', state: 'available' }) +}) + +it('captures fresh shell facts for a first subscriber even when Refresh had no listener', async () => { + const capture = Promise.withResolvers() + let version = 'unsupported' + mocks.observe.mockImplementation(async () => version) + mocks.recapture.mockImplementation(() => capture.promise) + mocks.refreshed.forEach((listener) => listener()) + const { availability } = install() + const read = vi.fn() + availability.subscribe(read) + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.observe).not.toHaveBeenCalled() + version = 'supported' + capture.resolve() + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1)) + expect(read.mock.lastCall?.[0]).toContainEqual({ agent: 'opencode', state: 'available' }) +}) diff --git a/src/main/runtime/structured-agent-availability.ts b/src/main/runtime/structured-agent-availability.ts new file mode 100644 index 000000000000..9ba8bacc6530 --- /dev/null +++ b/src/main/runtime/structured-agent-availability.ts @@ -0,0 +1,108 @@ +// What `agentSession.subscribeAvailability` publishes: one health snapshot of this host's agents, +// shared by every reader and kept without them. Each agent's location rule and installed-release +// check run once, in the server's home rather than a workspace; a launch checks again in its own. + +import { homedir } from 'node:os' +import type { AgentSessionAgentAvailability } from '../../shared/agent-session-agent-availability' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { onAgentsRefreshedOnHost } from '../preflight/workspace-agent-detection' +import { observeStructuredAgentLaunch } from './structured-agent-launch-support' +import { STRUCTURED_AGENT_RUNTIME_METADATA } from './structured-agent-runtime-metadata' +import type { StructuredAgentRuntimeMetadata } from './structured-agent-runtime-registration-types' +import { recaptureHostShellEnvironment } from './structured-agent-shell-environment' +import type { RuntimeStore } from './runtime-store-contract' + +export type StructuredAgentAvailabilityReader = ( + agents: readonly AgentSessionAgentAvailability[] +) => void + +// Why: these pick the profile-loading shell the check's environment is captured from. +const SHELL_SETTINGS = ['terminalWindowsShell', 'terminalWindowsPowerShellImplementation'] as const +/** Every setting the snapshot reads, so a change to any of them can change it. */ +const HEALTH_SETTINGS = [ + 'agentCmdOverrides', + 'agentDefaultEnv', + 'nativeChatInheritShellEnvironment', + 'nativeChatShellEnvironmentVariables', + ...SHELL_SETTINGS +] as const satisfies readonly (keyof GlobalSettings)[] + +// Why native: health is this machine's; a workspace's WSL or remote route is the client's rule. +const HOST_LOCATION: AgentSessionExecutionLocation = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: '', + workspaceKind: 'folder' +} + +type HealthRuntime = Parameters[2] + +async function observeHostAgent( + { definition: { agent }, supportsLocation }: StructuredAgentRuntimeMetadata, + runtime: HealthRuntime +): Promise { + if (!supportsLocation(HOST_LOCATION)) { + return { agent, state: 'refused', reason: 'agent' } + } + // A failed probe or an unrunnable Command setting says nothing about the installed release. + const seen = await observeStructuredAgentLaunch( + agent, + async () => homedir(), + runtime, + false + ).catch(() => 'inconclusive' as const) + if (seen === 'inconclusive') { + return { agent, state: 'unknown' } + } + return seen === 'supported' + ? { agent, state: 'available' } + : { agent, state: 'refused', reason: 'agent' } +} + +/** The runtime's snapshot: taken after a fresh shell capture, initially and on launch settings or + * agent Refresh. No reader gates it, so a later one reads it at once. */ +export function installStructuredAgentAvailability( + store: Pick | null, + runtime: HealthRuntime +) { + const readers = new Set() + let latest: readonly AgentSessionAgentAvailability[] | undefined + let pass = 0 + const derive = (): void => { + const mine = ++pass + // Every replacement needs fresh facts, even if it supersedes Refresh or predates its listener. + void recaptureHostShellEnvironment() + .catch(() => undefined) + .then(() => + Promise.all( + STRUCTURED_AGENT_RUNTIME_METADATA.map((agent) => observeHostAgent(agent, runtime)) + ) + ) + .then((agents) => { + // A newer pass supersedes this one, so nothing is owed after it. + if (mine === pass) { + latest = agents + readers.forEach((read) => read(agents)) + } + }) + } + store?.onSettingsChanged?.((updates) => { + if (HEALTH_SETTINGS.some((key) => key in updates)) { + derive() + } + }) + onAgentsRefreshedOnHost(derive) + derive() + return { + /** Reads the latest snapshot and every later one; returns the release. */ + subscribe(read: StructuredAgentAvailabilityReader): () => void { + readers.add(read) + if (latest) { + read(latest) + } + return () => readers.delete(read) + } + } +} diff --git a/src/main/runtime/structured-agent-cli-writers.test.ts b/src/main/runtime/structured-agent-cli-writers.test.ts new file mode 100644 index 000000000000..41dacec2ba75 --- /dev/null +++ b/src/main/runtime/structured-agent-cli-writers.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { AgentProcessVerdict } from '../../shared/agent-process-presence' +import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-observation' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' +import { + assertCliConversationWritersExited, + type CliWriterHost +} from './structured-agent-cli-writers' +import { OrcaRuntimeService } from './orca-runtime' +import { + OPEN_CODE_WRITER_TOPOLOGY, + tmuxCliWriterEvent +} from '../agent-hooks/server/cli-writer-observation' + +const snapshot = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getFreshShellForegroundSnapshot: snapshot +})) +const probe = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) + +const row: AgentStatusIpcPayload = { + paneKey: 'source', + worktreeId: 'workspace', + connectionId: null, + agentType: 'codex', + receivedAt: 1, + stateStartedAt: 1, + state: 'done', + prompt: '', + providerSession: { key: 'session_id', id: 'conversation' } +} +function inspection(verdict: AgentProcessVerdict | null = null) { + let current = true + let identity = 'original-pty' + const observation: CliWriterObservation = { + current: () => current, + topology: 'pane-owned', + presence: vi.fn(async () => verdict) + } + const host: CliWriterHost = { + rows: () => [row], + saved: () => [], + observation: () => observation, + sleeping: () => false, + terminal: () => ({ pane: { ptyId: 'pty', incarnationId: identity, identity } }), + links: () => [], + shellAlone: vi.fn(async () => true) + } + return { + host, + observation, + replaceRow: () => { + current = false + }, + replacePty: () => { + identity = 'replacement-pty' + }, + run: () => assertCliConversationWritersExited(host, 'codex', 'conversation') + } +} +const refusal = { + refusal: { code: 'agent_session_conflict', details: { reason: 'conversationHeldElsewhere' } } +} + +beforeEach(() => vi.clearAllMocks()) +describe('execution-host conversation writer inspection', () => { + it('accepts exact exited or unchanged shell-only proof, never loss of evidence', async () => { + await expect(inspection('unverifiable').run()).rejects.toMatchObject(refusal) + for (const cause of ['missing-row', 'sleeping', 'failed-scan', 'unknown-topology'] as const) { + const check = inspection() + if (cause === 'missing-row') { + check.host.observation = () => null + } + if (cause === 'sleeping') { + check.host.sleeping = () => true + } + if (cause === 'failed-scan') { + check.host.rows = () => { + throw new Error('unavailable') + } + } + if (cause === 'unknown-topology') { + check.observation.topology = 'unverifiable' + } + await expect(check.run()).rejects.toMatchObject(refusal) + } + }) + + it('refuses replaced conversation observations or terminal incarnations across awaits', async () => { + for (const replace of ['row', 'pty'] as const) { + const check = inspection('exited') + check.observation.presence = async () => { + if (replace === 'row') { + check.replaceRow() + } else { + check.replacePty() + } + return 'exited' + } + await expect(check.run()).rejects.toMatchObject(refusal) + } + }) + + it('requires current owned shell proof for saved resumes, including done checkpoints', async () => { + const record: SleepingAgentSessionRecord = { + paneKey: 'source', + worktreeId: 'workspace', + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + prompt: '', + state: 'done', + origin: 'live', + capturedAt: 1, + updatedAt: 1 + } + const check = inspection('exited') + let saved = [record] + check.host.saved = () => saved + check.host.rows = () => [] + check.host.sleeping = () => true + await expect(check.run()).rejects.toMatchObject(refusal) + check.host.sleeping = () => false + const terminal = check.host.terminal + check.host.terminal = () => ({ pane: null }) + await expect(check.run()).rejects.toMatchObject(refusal) + check.host.terminal = terminal + await expect(check.run()).resolves.toBeUndefined() + await expect( + assertCliConversationWritersExited( + { ...check.host, saved: () => [{ ...record, agent: 'opencode2' }] }, + 'opencode', + 'conversation' + ) + ).rejects.toMatchObject(refusal) + saved = [{ ...record, connectionId: 'ssh-A' }] + await expect(check.run()).resolves.toBeUndefined() + saved = [record] + check.host.rows = () => [{ ...row, providerSession: { key: 'session_id', id: 'other' } }] + await expect(check.run()).rejects.toMatchObject(refusal) + check.host.rows = () => [row] + await expect(check.run()).resolves.toBeUndefined() + expect(check.host.shellAlone).toHaveBeenCalledTimes(2) + check.host.shellAlone = async () => { + saved = [] + return true + } + await expect(check.run()).rejects.toMatchObject(refusal) + saved = [record] + check.host.shellAlone = async () => { + check.replacePty() + return true + } + await expect(check.run()).rejects.toMatchObject(refusal) + const newlySaved = inspection() + newlySaved.host.saved = () => saved + saved = [] + newlySaved.host.shellAlone = async () => { + saved = [record] + return true + } + await expect(newlySaved.run()).rejects.toMatchObject(refusal) + const noRows = inspection() + noRows.host.saved = () => saved + noRows.host.rows = () => [] + noRows.host.shellAlone = async () => { + noRows.host.rows = () => [ + { ...row, connectionId: 'ssh-A', providerSession: undefined, structuredHost: 'owned' } + ] + return true + } + await expect(noRows.run()).rejects.toMatchObject(refusal) + noRows.host.rows = () => [] + noRows.host.shellAlone = async () => { + record.providerSession.transcriptPath = 'changed' + return true + } + await expect(noRows.run()).rejects.toMatchObject(refusal) + noRows.host.shellAlone = async () => { + noRows.replacePty() + return true + } + await expect(noRows.run()).rejects.toMatchObject(refusal) + }) + + it('inspects the terminal a conversation leaves, whatever its rows now say', async () => { + const leaf = '11111111-1111-4111-8111-111111111111' + const pane = `tab-a:${leaf}` + let rows: AgentStatusIpcPayload[] = [] + const runtime = new OrcaRuntimeService(null, undefined, { + getAgentProviderSessionSnapshot: () => rows, + readHookCliWriterObservation: () => ({ + current: () => true, + topology: 'pane-owned', + presence: async () => null + }) + }) + const register = (tabId: string, incarnationId?: string, isWsl?: boolean) => + runtime.registerPty('pty', 'workspace', null, { tabId, leafId: leaf, incarnationId }, isWsl) + register('tab-a', 'i1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only listProcesses. + runtime.setPtyController({ + listProcesses: async () => [{ id: 'pty', rootProcessId: 10, incarnationId: 'i1' }] + } as never) + const event = (agentType: string, extra?: Partial) => ({ + paneKey: pane, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id' as const, id: 'conversation' }, + payload: { state: 'done' as const, prompt: '', agentType }, + ...extra + }) + const report = (agentType: string, extra?: Partial) => + runtime.recordCliWriterConversation(event(agentType, extra)) + const check = ( + agent: 'claude' | 'opencode', + source?: { workspaceId: string; paneKey: string } + ) => runtime.assertStructuredAdoptionTerminalWritersExited(agent, 'conversation', source) + const shell = { pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' } + const census = async (run: () => Promise) => { + // A prompt's zpty worker left the session; any suspended or background job counts, whatever its name. + const worker = [ + shell, + { pid: 20, ppid: 10, stat: 'Ss+', command: '-zsh -i' }, + { pid: 21, ppid: 20, stat: 'S+', command: 'git status' } + ] + for (const stat of ['T', 'SN']) { + snapshot.mockResolvedValue([...worker, { pid: 22, ppid: 10, stat, command: 'agent-alias' }]) + await expect(run()).rejects.toMatchObject(refusal) + } + snapshot.mockResolvedValue(worker) + await expect(run()).resolves.toBeUndefined() + } + // The header switch inspects the pane it leaves even when nothing recorded the conversation. + await census(() => check('claude', { workspaceId: 'workspace', paneKey: pane })) + // A row restored after restart has no observed identity; the pane's current PTY decides. + rows = [{ ...row, paneKey: pane, agentType: 'claude' }] + await census(() => check('claude')) + rows = [] + // Paused, then a second run overwrote the row and the pane moved: the link is the execution's. + report('claude') + report('claude', { providerSession: { key: 'session_id', id: 'later-run' } }) + register('tab-b', 'i1') + await census(() => check('claude')) + // The agent's own process can outlive the terminal's tree, as in detached tmux or nohup. + report('claude', { + agentPresence: { agent: 'claude', process: { pid: 12, platform: 'darwin', startTime: 's' } } + }) + for (const verdict of ['live', 'unverifiable', 'exited'] as const) { + probe.mockResolvedValueOnce(verdict) + const result = expect(check('claude')) + await (verdict === 'exited' + ? result.resolves.toBeUndefined() + : result.rejects.toMatchObject(refusal)) + } + // An unmarked binding copy keeps a proven pane-owned link; an uninspectable PTY or tmux refuses. + const proven = { key: 'session_id' as const, id: 'conversation' } + Object.defineProperty(proven, OPEN_CODE_WRITER_TOPOLOGY, { value: 'pane-owned' }) + report('opencode2', { providerSession: proven }) + report('opencode2') + await census(() => check('opencode')) + register('tab-b', undefined, true) + await expect(check('opencode')).rejects.toMatchObject(refusal) + register('tab-b', undefined, false) + runtime.recordCliWriterConversation(tmuxCliWriterEvent(event('opencode2'))) + await expect(check('opencode')).rejects.toMatchObject(refusal) + // A new incarnation is a new execution: the old one's links died with it. + register('tab-b', 'i2') + await expect(check('opencode')).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-cli-writers.ts b/src/main/runtime/structured-agent-cli-writers.ts new file mode 100644 index 000000000000..0cf3524279bb --- /dev/null +++ b/src/main/runtime/structured-agent-cli-writers.ts @@ -0,0 +1,240 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import { + adoptsCliConversations, + chatAgentsShareConversations, + CLI_CONVERSATION_ADOPTING_AGENTS +} from '../../shared/structured-agent-cli-conversation-adoption' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-observation' +import { getFreshShellForegroundSnapshot } from '../../shared/process-table-snapshot-reader' +import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index' +import { isShellProcess } from '../../shared/shell-process-detection' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { AgentProcessIdentity } from '../../shared/agent-process-presence' +import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' +import { + normalizeAgentProviderSession, + type SleepingAgentSessionRecord +} from '../../shared/agent-session-resume' + +export type CliWriterPane = { ptyId: string; incarnationId: PtyIncarnationId; identity: string } +type CliWriterCoordinates = { paneKey: string; worktreeId?: string } +/** `pane` is null when the PTY can't be inspected: remote, WSL, disconnected or unresolvable. */ +export type CliWriterTerminal = { pane: CliWriterPane | null } +/** A conversation one PTY execution reported; `process` is the agent the hooks last proved. */ +export type CliWriterConversation = { + agent: string + id: string + paneOwned: boolean + process?: AgentProcessIdentity +} +export type CliWriterLink = CliWriterConversation & { + worktreeId: string + terminal: () => CliWriterTerminal | null +} +export type CliWriterHost = { + rows: () => AgentStatusIpcPayload[] + saved: () => SleepingAgentSessionRecord[] + observation: (row: AgentStatusIpcPayload) => CliWriterObservation | null + /** Null when nothing in the pane can still write: no PTY, or one whose exit was certified. */ + terminal: (pane: CliWriterCoordinates) => CliWriterTerminal | null + /** Conversations each live PTY's current execution reported, outliving the rows that named them. */ + links: () => CliWriterLink[] + sleeping: (workspaceId: string) => boolean + shellAlone: (pane: CliWriterPane) => Promise +} + +/** Unlike foreground ownership, any job left in the shell's session refuses, background and suspended too. */ +export async function posixCliWriterShellAlone(rootPid: number): Promise { + const index = getProcessTableIndex(await getFreshShellForegroundSnapshot()) + const root = index.byPid.get(rootPid) + const executable = root?.command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? '' + return ( + !!root && + isShellProcess(executable) && + root.stat.includes('+') && + // A session leader below the shell has left this terminal (a prompt's zpty worker); its jobs never do. + collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).length === 0 + ) +} + +/** Execution-host observations release a terminal writer; loss of evidence never does. */ +export async function assertCliConversationWritersExited( + host: CliWriterHost, + agent: StructuredAgentId, + providerSessionId: string, + source?: CliWriterCoordinates +): Promise { + const refuse = () => + agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere' }) + const isConversation = (rowAgent: unknown, id: string | undefined) => + adoptsCliConversations(rowAgent) && + CLI_CONVERSATION_ADOPTING_AGENTS[rowAgent].some((rowChat) => + chatAgentsShareConversations(rowChat, agent) + ) && + id === providerSessionId + const paneOf = (coordinates: CliWriterCoordinates) => host.terminal(coordinates)?.pane ?? null + // The pane's PTY itself, not a recorded pid: a wrapper pid can read exited beside a paused agent. + const censusPane = async ( + read: () => CliWriterTerminal | null, + worktreeId: string | undefined, + absentAdmits: boolean + ) => { + const terminal = read() + if (!terminal && absentAdmits) { + return + } + const pane = terminal?.pane + if ( + !pane || + host.sleeping(worktreeId ?? '') || + !(await host.shellAlone(pane)) || + read()?.pane?.identity !== pane.identity + ) { + throw refuse() + } + } + try { + if (source) { + await censusPane(() => host.terminal(source), source.worktreeId, false) + } + const savedCandidates = () => + host + .saved() + .filter( + (record) => + !record.connectionId && + isConversation(record.agent, normalizeAgentProviderSession(record.providerSession)?.id) + ) + const saved = savedCandidates() + const savedIdentity = (record: SleepingAgentSessionRecord) => + [ + record.paneKey, + record.worktreeId, + record.connectionId, + record.agent, + record.providerSession.key, + record.providerSession.id, + record.providerSession.transcriptPath, + record.capturedAt, + record.updatedAt + ] as const + const savedIdentities = new Map(saved.map((record) => [record, savedIdentity(record)] as const)) + const savedCurrent = (record: SleepingAgentSessionRecord) => + savedCandidates().includes(record) && + savedIdentity(record).every((value, index) => value === savedIdentities.get(record)?.[index]) + const covered = new Map boolean>() + const rows = host + .rows() + .filter( + (row) => + !row.connectionId && + !row.structuredHost && + isConversation(row.agentType, row.providerSession?.id) + ) + for (const row of rows) { + const matches = (record: SleepingAgentSessionRecord) => + !record.connectionId && + record.paneKey === row.paneKey && + record.worktreeId === row.worktreeId && + record.agent === row.agentType && + record.providerSession.key === row.providerSession?.key && + record.providerSession.id === row.providerSession?.id + const matching = saved.filter(matches) + const observed = host.observation(row) + if ( + !observed || + !observed.current() || + observed.topology !== 'pane-owned' || + host.sleeping(row.worktreeId ?? '') + ) { + throw refuse() + } + const pane = paneOf(row) + const presence = await observed.presence() + if ( + !observed.current() || + presence === 'live' || + presence === 'unverifiable' || + (pane && paneOf(row)?.identity !== pane.identity) || + host.sleeping(row.worktreeId ?? '') + ) { + throw refuse() + } + if (presence === 'exited' && matching.length === 0) { + await censusPane(() => host.terminal(row), row.worktreeId, true) + if (!observed.current()) { + throw refuse() + } + continue + } + if ( + !pane || + !(await host.shellAlone(pane)) || + !observed.current() || + paneOf(row)?.identity !== pane.identity || + host.sleeping(row.worktreeId ?? '') || + matching.some((record) => !host.saved().includes(record) || !matches(record)) + ) { + throw refuse() + } + for (const record of matching) { + covered.set( + record, + () => + observed.current() && + paneOf(row)?.identity === pane.identity && + !host.sleeping(row.worktreeId ?? '') && + matches(record) && + host.saved().includes(record) + ) + } + } + for (const link of host.links()) { + if (!isConversation(link.agent, link.id)) { + continue + } + // The agent can outlive the terminal's tree (detached tmux, nohup); its probe only adds refusals. + if ( + !link.paneOwned || + (link.process && (await probeAgentProcessPresence(link.process)) !== 'exited') + ) { + throw refuse() + } + await censusPane(link.terminal, link.worktreeId, false) + } + for (const [record, identity] of savedIdentities) { + if (covered.has(record)) { + continue + } + const [paneKey, worktreeId, , savedAgent] = identity + if (savedAgent === 'opencode' || savedAgent === 'opencode2') { + throw refuse() + } + const coordinates = { paneKey, worktreeId } + const pane = paneOf(coordinates) + if (!pane) { + throw refuse() + } + const current = () => + savedCurrent(record) && + !host.sleeping(worktreeId) && + paneOf(coordinates)?.identity === pane.identity && + !host.rows().some((row) => row.paneKey === paneKey) + if (!current() || !(await host.shellAlone(pane)) || !current()) { + throw refuse() + } + covered.set(record, current) + } + if ( + [...saved, ...savedCandidates()].some( + (record) => !savedCurrent(record) || !covered.get(record)?.() + ) + ) { + throw refuse() + } + } catch { + throw refuse() + } +} diff --git a/src/main/runtime/structured-agent-launch-composition.ts b/src/main/runtime/structured-agent-launch-composition.ts index e868f0f10338..cf69fe4eba01 100644 --- a/src/main/runtime/structured-agent-launch-composition.ts +++ b/src/main/runtime/structured-agent-launch-composition.ts @@ -65,8 +65,11 @@ export type StructuredLaunchRequest = { permissionMode?: AgentChatPermissionMode } +/** What the composition sets on a launch; an agent's part never does. */ +type ComposedLaunchFields = 'cwd' | 'env' | 'envToDelete' | 'commandEnv' + export type StructuredAgentLaunchPart = { - launch: Omit + launch: Omit /** The agent's environment, before the seal names the child. */ env: Record /** Inherited keys this agent's child must not keep; required, so an agent states even none. */ @@ -81,10 +84,12 @@ export type StructuredAgentLaunchPartResolver = ( identity: AgentSessionJournalIdentity ) => Promise> -export type ComposedStructuredLaunch = Omit & { +export type ComposedStructuredLaunch = Omit & { cwd: string env: Record envToDelete: string[] + /** See `SealedStructuredSessionChild`; absent from a read, which seals no child. */ + commandEnv?: Record } /** The child to spawn, sealed with the reservation's token. */ @@ -189,7 +194,13 @@ export function composeStructuredLaunch( inheritedEnvToDelete, ...(cliRuntimeCommand ? { cliRuntimeCommand } : {}) }) - return { ...launch, cwd, env: child.env, envToDelete: child.envToDelete } + return { + ...launch, + cwd, + env: child.env, + envToDelete: child.envToDelete, + commandEnv: child.commandEnv + } } } diff --git a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts index c759cfdeeb1e..cda3b3ddd1f2 100644 --- a/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe-omp.test.ts @@ -36,11 +36,9 @@ async function fakeOmp(dir: string, version: string): Promise { } function runtimeWith(settings: Settings) { - return { - requireStore: () => ({ getSettings: () => settings }), - resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) - } + return { requireStore: () => ({ getSettings: () => settings }) } } +const workspace = async () => root function launchWith(settings: Settings) { return createAcpStructuredLaunchResolver(acpLaunchSpecFor('omp')!, { @@ -91,7 +89,7 @@ describe.skipIf(process.platform === 'win32')('OMP create check and launch, real agentDefaultEnv: {}, agentCmdOverrides: { omp: join(privateBin, 'omp') } } - expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + expect(await structuredAgentSupportsLaunch('omp', workspace, runtimeWith(settings))).toBe(true) const launch = await launchWith(settings) expect(launch.command).toBe(join(privateBin, 'omp')) expect(launch.args).toEqual(['acp']) @@ -99,13 +97,13 @@ describe.skipIf(process.platform === 'win32')('OMP create check and launch, real it('asks the OMP the per-agent PATH names, not the one first on the shell PATH', async () => { const settings = { ...base, agentDefaultEnv: { omp: { PATH: `${privateBin}:/usr/bin:/bin` } } } - expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + expect(await structuredAgentSupportsLaunch('omp', workspace, runtimeWith(settings))).toBe(true) expect((await launchWith(settings)).command).toBe(join(privateBin, 'omp')) }) it('keeps an OMP older than 17.0.5 on its terminal-backed chat', async () => { const settings = { ...base, agentDefaultEnv: {} } - expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(false) + expect(await structuredAgentSupportsLaunch('omp', workspace, runtimeWith(settings))).toBe(false) }) it('refuses the launch with the setting named when the Command is not runnable', async () => { @@ -115,7 +113,7 @@ describe.skipIf(process.platform === 'win32')('OMP create check and launch, real agentCmdOverrides: { omp: join(root, 'missing', 'omp') } } // Admitted, so the chat states why rather than silently opening a terminal. - expect(await structuredAgentSupportsLaunch('omp', 'id:w', runtimeWith(settings))).toBe(true) + expect(await structuredAgentSupportsLaunch('omp', workspace, runtimeWith(settings))).toBe(true) await expect(launchWith(settings)).rejects.toMatchObject({ name: 'AgentSessionPreSpawnError', reason: 'agentCommandNotRunnable' diff --git a/src/main/runtime/structured-agent-launch-support-probe.test.ts b/src/main/runtime/structured-agent-launch-support-probe.test.ts index 02640b3bcba6..847df2c3a65d 100644 --- a/src/main/runtime/structured-agent-launch-support-probe.test.ts +++ b/src/main/runtime/structured-agent-launch-support-probe.test.ts @@ -36,11 +36,9 @@ async function fakeOpencode(dir: string, version: string): Promise { } function runtimeWith(settings: Settings) { - return { - requireStore: () => ({ getSettings: () => settings }), - resolveRuntimeFileTarget: async () => ({ worktree: { path: root } }) - } + return { requireStore: () => ({ getSettings: () => settings }) } } +const workspace = async () => root function launchWith(settings: Settings) { return createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { @@ -92,25 +90,25 @@ describe.skipIf(process.platform === 'win32')( ...base, agentDefaultEnv: { opencode: { PATH: `${privateBin}:/usr/bin:/bin` } } } - expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( - true - ) + expect( + await structuredAgentSupportsLaunch('opencode', workspace, runtimeWith(settings)) + ).toBe(true) expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) }) it('keeps a refused release first on PATH on the terminal chat when nothing points elsewhere', async () => { const settings = { ...base, agentDefaultEnv: {} } - expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( - false - ) + expect( + await structuredAgentSupportsLaunch('opencode', workspace, runtimeWith(settings)) + ).toBe(false) }) it('runs a stable 2.x first on PATH in the structured chat', async () => { await fakeOpencode(shellBin, '2.0.21') const settings = { ...base, agentDefaultEnv: {} } - expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( - true - ) + expect( + await structuredAgentSupportsLaunch('opencode', workspace, runtimeWith(settings)) + ).toBe(true) expect((await launchWith(settings)).command).toBe(join(shellBin, 'opencode')) }) @@ -120,9 +118,9 @@ describe.skipIf(process.platform === 'win32')( agentDefaultEnv: {}, agentCmdOverrides: { opencode: join(privateBin, 'opencode') } } - expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( - true - ) + expect( + await structuredAgentSupportsLaunch('opencode', workspace, runtimeWith(settings)) + ).toBe(true) expect((await launchWith(settings)).command).toBe(join(privateBin, 'opencode')) }) @@ -133,9 +131,9 @@ describe.skipIf(process.platform === 'win32')( agentCmdOverrides: { opencode: join(root, 'missing', 'opencode') } } // Admitted, so the chat states why rather than silently opening a terminal. - expect(await structuredAgentSupportsLaunch('opencode', 'id:w', runtimeWith(settings))).toBe( - true - ) + expect( + await structuredAgentSupportsLaunch('opencode', workspace, runtimeWith(settings)) + ).toBe(true) await expect(launchWith(settings)).rejects.toMatchObject({ name: 'AgentSessionPreSpawnError', reason: 'agentCommandNotRunnable' diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index cc0fd4d7d2d0..4d7ad5b22358 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -13,7 +13,7 @@ import { type ResolveLoginShellEnvironmentOptions as LoginShellOptions } from '../startup/login-shell-environment' -const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { +const { observeAgentCliVersion, probeAgentCliVersion, loginShell } = vi.hoisted(() => { const loginShell: { env: NodeJS.ProcessEnv spawn: ReturnType Promise>> @@ -22,10 +22,11 @@ const { probeAgentCliVersion, loginShell } = vi.hoisted(() => { // Each call is one shell run through the real cache; a test can hold one open. spawn: vi.fn(async (): Promise => loginShell.env) } - return { probeAgentCliVersion: vi.fn(), loginShell } + return { observeAgentCliVersion: vi.fn(), probeAgentCliVersion: vi.fn(), loginShell } }) vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ ...(await importOriginal()), + observeAgentCliVersion, probeAgentCliVersion })) vi.mock('../startup/login-shell-environment', async (importOriginal) => { @@ -44,6 +45,11 @@ vi.mock('../startup/login-shell-environment', async (importOriginal) => { let bin: string const asked: { program: string; cwd: string; env: Record }[] = [] function installedVersion(version: string): void { + // The create check observes, the launch projects; both ask the same binary. + observeAgentCliVersion.mockImplementation(async (input, supports) => { + asked.push(input) + return supports(version) ? 'supported' : 'unsupported' + }) probeAgentCliVersion.mockImplementation(async (input, supports) => { asked.push(input) return supports(version) @@ -55,10 +61,8 @@ const settings = { nativeChatInheritShellEnvironment: true, nativeChatShellEnvironmentVariables: [] } -const runtime = { - requireStore: () => ({ getSettings: () => settings }), - resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repo/worktree' } })) -} +const runtime = { requireStore: () => ({ getSettings: () => settings }) } +const workspace = vi.fn(async () => '/repo/worktree') beforeEach(async () => { asked.length = 0 @@ -71,16 +75,17 @@ beforeEach(async () => { resetLoginShellEnvironmentCacheForTests() }) afterEach(async () => { + observeAgentCliVersion.mockReset() probeAgentCliVersion.mockReset() - runtime.resolveRuntimeFileTarget.mockClear() + workspace.mockClear() await rm(bin, { recursive: true, force: true }) }) describe('structuredAgentSupportsLaunch', () => { it('asks nothing for an agent whose location alone decides', async () => { - expect(await structuredAgentSupportsLaunch('grok', 'id:workspace-1', runtime)).toBe(true) - expect(probeAgentCliVersion).not.toHaveBeenCalled() - expect(runtime.resolveRuntimeFileTarget).not.toHaveBeenCalled() + expect(await structuredAgentSupportsLaunch('grok', workspace, runtime)).toBe(true) + expect(observeAgentCliVersion).not.toHaveBeenCalled() + expect(workspace).not.toHaveBeenCalled() }) it('allows stable OpenCode 1.x and 2.x, asking in the workspace with the launch env', async () => { @@ -94,9 +99,7 @@ describe('structuredAgentSupportsLaunch', () => { ['3.0.0', false] ] as const) { installedVersion(version) - expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe( - supported - ) + expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(supported) } expect(asked[0]).toMatchObject({ cwd: '/repo/worktree', @@ -126,7 +129,7 @@ describe('structuredAgentSupportsLaunch', () => { loginShell.spawn.mockReturnValueOnce(reread.promise) const askedAt = Date.now() const checks = Array.from({ length: 8 }, () => - structuredAgentSupportsLaunch('opencode', 'id:workspace-1', installing) + structuredAgentSupportsLaunch('opencode', workspace, installing) ) const launchMiss = launches.refreshAfterMissingProgram(askedAt) await vi.waitFor(() => expect(loginShell.spawn).toHaveBeenCalledTimes(2)) @@ -141,23 +144,23 @@ describe('structuredAgentSupportsLaunch', () => { it('never rereads the shell for an OpenCode that is installed but unsupported', async () => { installedVersion('1.18.30') - expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) - expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(false) + expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(false) // Only the first read of the shell; neither refusal asked for another. expect(loginShell.spawn).toHaveBeenCalledOnce() expect(asked).toHaveLength(2) }) it('refuses when the version cannot be read', async () => { - probeAgentCliVersion.mockResolvedValue(false) - expect(await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime)).toBe(false) + observeAgentCliVersion.mockResolvedValue('inconclusive') + expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(false) }) it.skipIf(process.platform === 'win32')( 'asks about the same binary the launch then spawns', async () => { installedVersion('1.18.31') - await structuredAgentSupportsLaunch('opencode', 'id:workspace-1', runtime) + await structuredAgentSupportsLaunch('opencode', workspace, runtime) const launch = await createAcpStructuredLaunchResolver(acpLaunchSpecFor('opencode')!, { store: { getRecord: () => ({ diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 06e65f711357..7a84340252f9 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -3,6 +3,7 @@ import type { StructuredAgentId } from '../../shared/agent-session-provider-hand import type { GlobalSettings } from '../../shared/global-settings-types' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { observeAgentCliVersion, type AgentCliVersionObservation } from '../agent-cli-version-probe' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -28,42 +29,52 @@ type LaunchEnvironmentSettings = Pick< Partial> /** What the check reads from the runtime asking it. */ -type LaunchSupportRuntime = { - requireStore(): { getSettings(): LaunchEnvironmentSettings } +type LaunchSettingsRuntime = { requireStore(): { getSettings(): LaunchEnvironmentSettings } } +type LaunchSupportRuntime = LaunchSettingsRuntime & { resolveRuntimeFileTarget(selector: string): Promise<{ worktree: { path: string } }> } -/** The agent's own check of what is installed on this host, with the environment and Command - * setting its launch starts from, in the workspace it would launch in; true for an agent whose - * location alone decides. */ -export async function structuredAgentSupportsLaunch( +/** The agent's own check of the release installed on this host, with the environment and Command + * setting its launch starts from, run in `cwd`; supported for an agent whose location alone + * decides. Throws the launch's pre-spawn refusal for an unrunnable Command setting. */ +export async function observeStructuredAgentLaunch( agent: string, - worktreeSelector: string, - runtime: LaunchSupportRuntime -): Promise { + cwd: () => Promise, + runtime: LaunchSettingsRuntime, + refreshShellOnMiss: boolean +): Promise { const check = structuredAgentRuntimeMetadata(agent)?.launchCheck if (!check) { - return true + return 'supported' } const settings = runtime.requireStore().getSettings() - let worktreePath: Promise | undefined - const cwd = () => - (worktreePath ??= runtime - .resolveRuntimeFileTarget(worktreeSelector) - .then((target) => target.worktree.path)) - let launch: { command: string; env: Record } + let directory: Promise | undefined + const once = () => (directory ??= cwd()) + // Resolved as a launch resolves, sharing its refresh, so an agent installed since the shell was + // last read counts and a found one never waits. + const launch = await resolveAgainAfterRefreshOnMissingProgram( + async () => + check.resolveLaunch( + await resolveHostStructuredAgentLaunchEnvironment(agent, settings), + settings + ), + refreshShellOnMiss ? refreshHostAgentBaseEnvironmentAfterMiss : undefined, + { env: ({ env }) => env, cwd: once } + ) + return observeAgentCliVersion( + { program: launch.command, cwd: await once(), env: launch.env }, + check.supportsVersion + ) +} + +/** Admission's answer in the workspace `cwd` names: only an observed supported release launches. */ +export async function structuredAgentSupportsLaunch( + agent: string, + cwd: () => Promise, + runtime: LaunchSettingsRuntime +): Promise { try { - // Resolved as a launch resolves, sharing its refresh, so an agent installed since the shell - // was last read counts and a found one never waits. - launch = await resolveAgainAfterRefreshOnMissingProgram( - async () => - check.resolveLaunch( - await resolveHostStructuredAgentLaunchEnvironment(agent, settings), - settings - ), - refreshHostAgentBaseEnvironmentAfterMiss, - { env: ({ env }) => env, cwd } - ) + return (await observeStructuredAgentLaunch(agent, cwd, runtime, true)) === 'supported' } catch (error) { // An unrunnable Command setting is the launch's refusal to state, not a terminal. if (isAgentSessionPreSpawnError(error)) { @@ -71,7 +82,6 @@ export async function structuredAgentSupportsLaunch( } throw error } - return check.supportsVersion({ ...launch, cwd: await cwd() }) } /** The environment a new local chat launch starts from, before its account binding. */ @@ -116,12 +126,16 @@ export async function resolveHostStructuredAgentCreateSupport(input: { location: AgentSessionExecutionLocation runtime: LaunchSupportRuntime }): Promise { - const { agent, location } = input + const { agent, location, runtime } = input const supportsLocation = structuredAgentRuntimeMetadata(agent)?.supportsLocation(location) ?? false const supportsLaunch = supportsLocation && - (await structuredAgentSupportsLaunch(agent, input.worktreeSelector, input.runtime)) + (await structuredAgentSupportsLaunch( + agent, + async () => (await runtime.resolveRuntimeFileTarget(input.worktreeSelector)).worktree.path, + runtime + )) const support = resolveStructuredAgentSessionCreateSupport({ location, adapterSupportsCreate: supportsLaunch diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index f0015e2121d1..daa82e277d3e 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -64,10 +64,10 @@ describe('the model catalog contract on every registration', () => { (registration) => registration.modelCatalog(context()).kind === 'probe' ).map((registration) => registration.definition.agent) expect(Object.keys(probes).sort()).toEqual([...listing].sort()) - // Every agent registered today but OMP and Antigravity lists its models without a session. + // Every agent registered today but Antigravity lists its models before a chat. expect(listing.sort()).toEqual( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition.agent) - .filter((agent) => agent !== 'omp' && agent !== 'antigravity') + .filter((agent) => agent !== 'antigravity') .sort() ) }) @@ -82,13 +82,13 @@ describe('the model catalog contract on every registration', () => { expect([...listingNamesConfiguredModel].sort()).toEqual(['codex']) }) - // OpenCode 2.x lists in a session opened in the chat's folder, which a prewarm does not have. + // OpenCode 2.x and OMP list in a session opened in the chat's folder, which a prewarm does not have. it('marks the agents whose listing runs in a chat folder', () => { const { listsInChatFolder } = registeredModelCatalogDiscovery( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, context() ) - expect([...listsInChatFolder].sort()).toEqual(['opencode', 'opencode2']) + expect([...listsInChatFolder].sort()).toEqual(['omp', 'opencode', 'opencode2']) }) it('maps an ACP agent with no session-free listing to an unavailable registration', () => { diff --git a/src/main/runtime/structured-agent-runtime-metadata.ts b/src/main/runtime/structured-agent-runtime-metadata.ts index 79a9ff63e5d9..e5b668db97a5 100644 --- a/src/main/runtime/structured-agent-runtime-metadata.ts +++ b/src/main/runtime/structured-agent-runtime-metadata.ts @@ -2,10 +2,7 @@ import { agentSessionAccountHome } from '../../shared/agent-session-account-home import { applyStructuredCodexWorkspaceTrust } from '../agent-workspace-trust-spawn' import { ACP_LAUNCH_SPECS, type AcpLaunchSpec } from '../acp/acp-launch-specs' import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' -import { - acpLaunchVersionSupported, - resolveAcpLaunchCommand -} from '../acp/acp-structured-launch-resolution' +import { resolveAcpLaunchCommand } from '../acp/acp-structured-launch-resolution' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import { CLAUDE_STRUCTURED_AGENT } from '../claude/claude-structured-agent-definition' import { CLAUDE_CLI_CONVERSATIONS } from '../claude/claude-cli-conversations' @@ -34,10 +31,11 @@ export type StructuredAgentRuntimeCatalogEntry = | { kind: 'acp'; metadata: StructuredAgentRuntimeMetadata; spec: AcpLaunchSpec } function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata { + const { supportsVersion } = spec return { definition: acpStructuredAgentDefinition(spec), supportsLocation: (location) => supportsAcpStructuredLocation(spec, location), - ...(spec.supportsVersion + ...(supportsVersion ? { launchCheck: { resolveLaunch: (shellEnv, commandSettings) => { @@ -48,7 +46,7 @@ function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata { }) return { command, env } }, - supportsVersion: (launch) => acpLaunchVersionSupported(spec, launch) + supportsVersion } } : {}), diff --git a/src/main/runtime/structured-agent-runtime-registration-types.ts b/src/main/runtime/structured-agent-runtime-registration-types.ts index 57065b1394e0..a64a4a87ddaf 100644 --- a/src/main/runtime/structured-agent-runtime-registration-types.ts +++ b/src/main/runtime/structured-agent-runtime-registration-types.ts @@ -73,12 +73,8 @@ export type StructuredAgentLaunchCheck = { env: Record, commandSettings: StructuredAgentCommandSettings ) => { command: string; env: Record } - /** Whether that binary is a release this agent runs a structured chat on. */ - supportsVersion: (launch: { - command: string - cwd: string - env: Record - }) => Promise + /** Whether a release that binary prints on `--version` runs a structured chat. */ + supportsVersion: (version: string) => boolean } export type StructuredAgentRuntimeMetadata = StructuredAgentCliConversations & { diff --git a/src/main/runtime/structured-agent-shell-environment.ts b/src/main/runtime/structured-agent-shell-environment.ts index e3f545c994a9..d9564e131641 100644 --- a/src/main/runtime/structured-agent-shell-environment.ts +++ b/src/main/runtime/structured-agent-shell-environment.ts @@ -112,6 +112,16 @@ export async function refreshHostAgentBaseEnvironmentAfterMiss(askedAt: number): ).catch(() => {}) } +/** Captures this host's shell env again now, so an explicit Refresh never reads a profile kept from + * before it (Windows keeps a loaded one for good). */ +export async function recaptureHostShellEnvironment(): Promise { + const startedAfter = Date.now() + await Promise.all([ + resolveLoginShellEnvironment({ startedAfter }), + readPersistedWindowsPathSegmentsAsync({ forceRefresh: true, startedAfter }) + ]).catch(() => {}) +} + /** The base env every local agent launch on this host starts from, read from current settings. */ export async function resolveHostAgentBaseEnvironment( settings: Pick< diff --git a/src/main/runtime/structured-session-child-env.ts b/src/main/runtime/structured-session-child-env.ts index f565a47886f4..7ba6ebd5a718 100644 --- a/src/main/runtime/structured-session-child-env.ts +++ b/src/main/runtime/structured-session-child-env.ts @@ -34,7 +34,7 @@ * remote selectors a shell profile may export are set empty, which the CLI reads as unset: empty * rather than deleted, because the child is spawned over Orca's own env, which may carry them. * Explicit `--environment` / `--pairing-code` flags still work, and a person's terminal keeps - * its selectors. A login shell the agent itself starts can export them again. + * its selectors. A login shell the agent starts can export them again, unless its agent sets them. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -84,6 +84,13 @@ export type SealedStructuredSessionChild = { env: Record /** Removed from what the child inherits, after `env` is laid over it. */ envToDelete: string[] + /** + * The session values the seal wrote, for an agent that rebuilds each command's env from its own + * policy (Codex `shell_environment_policy`), which drops them otherwise. Never PATH: Codex keeps + * it under `inherit = "core"`, and a set PATH would freeze it over a login shell's PATH restore. + * Never the spawn token, which is the child's alone. + */ + commandEnv: Record } /** @@ -100,34 +107,29 @@ export function sealStructuredSessionChild(input: { cliRuntimeCommand?: string }): SealedStructuredSessionChild { const identity = childIdentity(input.sessionId, input.env) - const named = new Set([ - ORCA_AGENT_SESSION_ID_ENV, - ORCA_STRUCTURED_SESSION_ENV, - ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV, - ...(identity.workerHandle ? ['ORCA_TERMINAL_HANDLE'] : []) - ]) + const named = new Set([...Object.keys(identity.commandEnv), ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]) const env = input.cliRuntimeCommand ? withCliRuntimeOnPath(input.cliRuntimeCommand, identity.env, { platform: process.platform }) : identity.env return { env: { ...env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: input.spawnToken }, - envToDelete: input.inheritedEnvToDelete.filter((key) => !named.has(key)) + envToDelete: input.inheritedEnvToDelete.filter((key) => !named.has(key)), + commandEnv: identity.commandEnv } } function childIdentity( sessionId: string, childEnv: Record -): { env: Record; workerHandle: string | null } { +): { env: Record; commandEnv: Record } { const identity = resolveStructuredWorkerIdentityForSession(sessionId, null) - const env: Record = { - ...childEnv, + const named: Record = { ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), [ORCA_AGENT_SESSION_ID_ENV]: sessionId, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - applyThisAppCli(env) - return { env, workerHandle: identity?.handle ?? null } + const env = { ...childEnv, ...named } + return { env, commandEnv: { ...named, ...applyThisAppCli(env) } } } const REMOTE_RUNTIME_SELECTOR_ENV_KEYS = [ @@ -137,31 +139,35 @@ const REMOTE_RUNTIME_SELECTOR_ENV_KEYS = [ ] as const /** - * A host with no app environment installed โ€” a plain-Node fork, or a unit test โ€” has no userData - * root to resolve, and inventing one would write a shim into the wrong directory. + * Returns the values it set, beside the PATH it prepends. A host with no app environment + * installed โ€” a plain-Node fork, or a unit test โ€” has no userData root to resolve, and inventing + * one would write a shim into the wrong directory. */ -function applyThisAppCli(env: Record): void { +function applyThisAppCli(env: Record): Record { delete env.ORCA_CLI_COMMAND if (!hasAppEnvironment()) { - return + return {} } const app = getAppEnvironment() const userDataPath = app.getPath('userData') const isPackaged = app.isPackaged() - env.ORCA_USER_DATA_PATH = userDataPath + const set: Record = { ORCA_USER_DATA_PATH: userDataPath } for (const key of REMOTE_RUNTIME_SELECTOR_ENV_KEYS) { - env[key] = '' + set[key] = '' } + Object.assign(env, set) const launcher = prependOrcaCliDirToChildPath(env, { isPackaged, userDataPath, resourcesPath: process.resourcesPath ?? null }) if (launcher) { + set.ORCA_CLI_COMMAND = launcher env.ORCA_CLI_COMMAND = launcher } else { console.warn( "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." ) } + return set } diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 0b0ccff3b7ef..9deac08a9ba8 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -31,7 +31,7 @@ import { structuredSessionTabRetired, structuredWorkerAddressable } from './structured-worker-custody' -import { AGENT_SESSION_FOUNDING_FENCE } from './agent-session-record-founding' +import { structuredSessionNeverStarted } from './agent-session-record-founding' import { isTuiAgent } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' import { isStructuredWorkerHandle } from '../../shared/structured-worker-handle' @@ -284,20 +284,6 @@ export function observeStructuredWorker( * owner, spawn, handoff or provider handle. A released reservation whose exit was never proven has * moved the fence, so it stays unverifiable. */ -function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { - const { lease } = record - return ( - lease.claimStatus === 'released' && - lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && - // A fence floor marks a copy restored from backup, which may hide a reservation it lost. - lease.minimumNextFence === undefined && - lease.ownerProcess === null && - lease.reservedSpawnToken === null && - lease.handoffStage === null && - record.providerHandleChain.length === 0 - ) -} - /** One session's own liveness, for callers that act on that session rather than on a worker. */ export function observeStructuredSession(sessionId: string): StructuredWorkerObservation { const host = getStructuredAgentSessionHost() diff --git a/src/main/runtime/terminal-list-payload-size.test.ts b/src/main/runtime/terminal-list-payload-size.test.ts index 98ddb3a00cb2..e90e7dac1328 100644 --- a/src/main/runtime/terminal-list-payload-size.test.ts +++ b/src/main/runtime/terminal-list-payload-size.test.ts @@ -30,7 +30,7 @@ const makeStore = () => ({ { id: REPO_ID, path: '/tmp/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 } ], addRepo: () => {}, - updateRepo: (id: string) => makeStore().getRepo(id) as never, + updateRepo: () => null, getAllWorktreeMeta: () => ({ [WORKTREE_ID]: { displayName: 'foo', diff --git a/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts b/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts index d7a4fac55276..770175c23c9d 100644 --- a/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts +++ b/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts @@ -8,6 +8,11 @@ import { } from '../../orca-runtime-test-fixtures.spec' import { createMobileCreateTestNotifier } from '../../orca-runtime-test-scenario-builders.spec' import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' +import { + clearPaneSpawnReservation, + makePaneSpawnReservationKey, + reservePaneSpawn +} from '../../../ipc/pty/pane/spawn-reservation' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) @@ -35,6 +40,36 @@ function graph(leafIds: string[], unboundLeafIds: string[] = []) { } } +type RuntimeInternals = { + recordPtyWorktree: ( + ptyId: string, + worktreeId: string, + opts: { connected: boolean; tabId: string; paneKey: string } + ) => void +} + +function internalsOf(runtime: OrcaRuntimeService): RuntimeInternals { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is declared on OrcaRuntimeService's inheritance chain. + return runtime as unknown as RuntimeInternals +} + +function graphWithSplit(newLeafId: string) { + const next = graph([HEADLESS_LEAF_ID, newLeafId]) + return { + ...next, + tabs: next.tabs.map((tab) => ({ + ...tab, + layout: { + type: 'split' as const, + direction: 'vertical' as const, + ratio: 0.5, + first: { type: 'leaf' as const, leafId: HEADLESS_LEAF_ID }, + second: { type: 'leaf' as const, leafId: newLeafId } + } + })) + } +} + describe('a split of a pane the window draws, in a layout-owner build', () => { function setup() { const store = makeRuntimeStoreWithWorkspaceSession(makeWorkspaceSessionWithHeadlessTerminal()) @@ -51,7 +86,7 @@ describe('a split of a pane the window draws, in a layout-owner build', () => { written.add(listener) return () => written.delete(listener) }) - const notifier = createMobileCreateTestNotifier(vi.fn()) + const notifier = { ...createMobileCreateTestNotifier(vi.fn()), closeTerminalPane: vi.fn() } const runtime = new OrcaRuntimeService(store.runtimeStore) runtime.setNotifier(notifier) runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID])) @@ -60,6 +95,7 @@ describe('a split of a pane the window draws, in a layout-owner build', () => { collectLayoutLeafIdsInOrder(store.getSession().terminalLayoutsByTabId[TAB_ID]!.root!) return { ...store, + notifier, runtime, handle: handle!, splitTerminal: vi.mocked(notifier.splitTerminal), @@ -67,26 +103,88 @@ describe('a split of a pane the window draws, in a layout-owner build', () => { } } - it('writes the new pane in main before the window mounts it, and keeps it however the start goes', async () => { - const { runtime, runtimeStore, handle, splitTerminal, leafIds } = setup() - const withdraw = vi.fn(async () => null) - Reflect.set(runtimeStore, 'withdrawTerminalPane', withdraw) + it('writes the new pane in main before the window mounts it, and takes it back when its terminal never starts', async () => { + const { runtime, handle, splitTerminal, leafIds, notifier } = setup() vi.useFakeTimers() try { const split = runtime.splitTerminal(handle, { direction: 'vertical', command: 'make test' }) - const rejected = expect(split).rejects.toThrow('Timed out waiting for split pane handle') + const rejected = expect(split).rejects.toThrow( + /^split_pane_not_started: .*did not start within 10 s.*closed/ + ) await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1)) const notice = splitTerminal.mock.calls[0]![2]! expect(notice).toMatchObject({ command: 'make test', sourceLeafId: HEADLESS_LEAF_ID }) expect(leafIds()).toEqual([HEADLESS_LEAF_ID, notice.newLeafId]) - // Its terminal never starts: main keeps a dead pane, as any, and takes nothing back. + // Its terminal never starts: main takes the pane back, and the window drops it too. await vi.advanceTimersByTimeAsync(11_000) await rejected - expect(leafIds()).toEqual([HEADLESS_LEAF_ID, notice.newLeafId]) - expect(withdraw).not.toHaveBeenCalled() + expect(leafIds()).toEqual([HEADLESS_LEAF_ID]) + expect(notifier.closeTerminalPane).toHaveBeenCalledWith(TAB_ID, notice.newLeafId) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a pane whose terminal bound before the wait ran out, though the window reported it late', async () => { + const { runtime, runtimeStore, handle, splitTerminal, leafIds, notifier } = setup() + + vi.useFakeTimers() + try { + const startedAt = Date.now() + const split = runtime.splitTerminal(handle, { direction: 'vertical' }) + await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1)) + const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId! + await vi.advanceTimersByTimeAsync(9_900 - (Date.now() - startedAt)) + // Main binds the started terminal at 9.9 s; the busy window's report lands after 10 s. + await runtimeStore.persistPtyBinding({ + worktreeId: TEST_WORKTREE_ID, + tabId: TAB_ID, + leafId: newLeafId, + ptyId: 'pty-1' + }) + await vi.advanceTimersByTimeAsync(600) + internalsOf(runtime).recordPtyWorktree('pty-1', TEST_WORKTREE_ID, { + connected: true, + tabId: TAB_ID, + paneKey: makePaneKey(TAB_ID, newLeafId) + }) + runtime.syncWindowGraph(1, graphWithSplit(newLeafId)) + await vi.advanceTimersByTimeAsync(0) + await expect(split).resolves.toMatchObject({ tabId: TAB_ID, leafId: newLeafId }) + expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId]) + expect(notifier.closeTerminalPane).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps the pane of a start still under way when the wait runs out, with the plain timeout', async () => { + const { runtime, handle, splitTerminal, leafIds, notifier } = setup() + const reservations: (() => void)[] = [] + + vi.useFakeTimers() + try { + const split = runtime.splitTerminal(handle, { direction: 'vertical' }) + const rejected = expect(split).rejects.toThrow(/^Timed out waiting for split pane handle$/) + await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1)) + const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId! + // A slow (SSH-style) start of the pane's terminal is under way and has not bound yet. + const reservationKey = makePaneSpawnReservationKey( + TEST_WORKTREE_ID, + 'ssh-1', + makePaneKey(TAB_ID, newLeafId) + )! + const reservation = reservePaneSpawn(reservationKey) + reservations.push(() => clearPaneSpawnReservation(reservationKey, reservation)) + + await vi.advanceTimersByTimeAsync(11_000) + await rejected + expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId]) + expect(notifier.closeTerminalPane).not.toHaveBeenCalled() } finally { + reservations.forEach((release) => release()) vi.useRealTimers() } }) @@ -147,7 +245,7 @@ describe('a split of a pane the window draws, in a layout-owner build', () => { vi.useFakeTimers() try { const split = runtime.splitTerminal(handle, { direction: 'vertical' }) - const rejected = expect(split).rejects.toThrow('Timed out waiting for split pane handle') + const rejected = expect(split).rejects.toThrow('split_pane_not_started') await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1)) // An unrelated write while the pane waits for its terminal. runtimeStore.setWorkspaceSession(runtimeStore.getWorkspaceSession()) diff --git a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts index 9903891552d0..50c516b9a888 100644 --- a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts +++ b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts @@ -13,6 +13,7 @@ import { } from '../../claude-agent-teams-shim-env' import { applyClaudeEnvPatch } from '../../../claude-accounts/environment' import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout' export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPtyBackedTerminal { abstract getTerminalHandleForPaneKey( @@ -185,7 +186,7 @@ export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } - reject(new Error('Timed out waiting for split pane handle')) + reject(new Error(SPLIT_PANE_WAIT_TIMEOUT_MESSAGE)) }, timeoutMs) const check = (): void => { diff --git a/src/main/runtime/terminal/launch/terminal-split-wait-timeout.ts b/src/main/runtime/terminal/launch/terminal-split-wait-timeout.ts new file mode 100644 index 000000000000..a9175c3e4385 --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-split-wait-timeout.ts @@ -0,0 +1,2 @@ +/** waitForLeafInTab's timeout; layout-owner splits tell it apart from their own failures. */ +export const SPLIT_PANE_WAIT_TIMEOUT_MESSAGE = 'Timed out waiting for split pane handle' diff --git a/src/main/runtime/terminal/launch/terminal-split.ts b/src/main/runtime/terminal/launch/terminal-split.ts index 68ec8925a9d0..22e58ed6432e 100644 --- a/src/main/runtime/terminal/launch/terminal-split.ts +++ b/src/main/runtime/terminal/launch/terminal-split.ts @@ -6,10 +6,15 @@ import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types' import { randomUUID } from 'node:crypto' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { admitRuntimePane } from '../../runtime-pane-admission' +import { admitRuntimePane, withdrawAdmittedPane } from '../../runtime-pane-admission' import type { RuntimeStore } from '../../runtime-store-contract' import type { Worktree } from '../../../../shared/worktree/types' import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { isPaneSpawnInFlight } from '../../../ipc/pty/pane/spawn-reservation' +import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout' + +const SPLIT_PANE_START_TIMEOUT_MS = 10_000 export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys { protected abstract splitPtyBackedTerminal( @@ -52,8 +57,8 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx const [hostId] = this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId) if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') { const newLeafId = randomUUID() - // Main keeps the pane however its start goes: one whose terminal never starts is a dead - // pane, as any. No pane written, no split: nothing else would put it in main's copy. + // No pane written, no split: nothing else would put it in main's copy. A pane whose + // terminal never starts is taken back below, so the CLI's failure is the truth. const outcome = await admitRuntimePane( this.store, { @@ -82,8 +87,50 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx () => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId), leaf.tabId, newLeafId, - this.waitForLeafInTab(leaf.tabId, newLeafId) - ) + this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS) + ).catch(async (error: unknown) => { + if ( + outcome !== 'admitted' || + !(error instanceof Error) || + error.message !== SPLIT_PANE_WAIT_TIMEOUT_MESSAGE + ) { + throw error + } + const taken = await withdrawAdmittedPane( + this.store, + { + type: 'splitPane', + workspace: leaf.worktreeId, + tabId: leaf.tabId, + leafId: leaf.leafId, + direction, + newLeafId + }, + hostId, + // Read in the write queue: a start under way (a slow SSH host) keeps its pane. + () => isPaneSpawnInFlight(leaf.worktreeId, makePaneKey(leaf.tabId, newLeafId)) + ).catch((withdrawError: unknown) => { + console.warn('[layout] an unstarted split pane stayed in main:', withdrawError) + return 'threw' as const + }) + if (taken === 'bound') { + // Bound just before the take-back: the window's late report brings its handle. + return settledByPaneClose( + this.store, + () => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId), + leaf.tabId, + newLeafId, + this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS) + ) + } + if (taken !== null) { + throw error + } + this.notifier?.closeTerminalPane?.(leaf.tabId, newLeafId) + throw new Error( + `split_pane_not_started: the new pane's terminal did not start within ${SPLIT_PANE_START_TIMEOUT_MS / 1000} s, so the pane was closed${opts.command ? ' and its command was not run' : ''}` + ) + }) return { handle: newHandle, tabId: leaf.tabId, diff --git a/src/main/runtime/terminal/stop/project-terminal-stop.ts b/src/main/runtime/terminal/stop/project-terminal-stop.ts index db723dc1b40b..1ac942109c41 100644 --- a/src/main/runtime/terminal/stop/project-terminal-stop.ts +++ b/src/main/runtime/terminal/stop/project-terminal-stop.ts @@ -2,10 +2,13 @@ import { OrcaRuntimeWithStopExactTerminalsForWorktree } from './worktree-termina import { getRepoExecutionHostId, getRepoSshConnectionId, - normalizeHostPart + normalizeHostPart, + parseExecutionHostId } from '../../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { Repo } from '../../../../shared/repo-types' +import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' +import { listFailedAgentLaunchWorktreeIds } from '../../../agent-launch/agent-launch-pane-attachment' import { closeStructuredSessionsForWorktree, createStructuredSweepProgress, @@ -101,17 +104,44 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime console.warn('[runtime] project agent session close failed', error) }) : null + let worktreeIds: readonly string[] = [] try { - return await this.stopProjectPtys(repo, stopOptions) + const result = await this.stopProjectPtys(repo, stopOptions) + worktreeIds = result.worktreeIds + return { stopped: result.stopped } } finally { await chats + if (closeStructuredSessions) { + this.releaseProjectHostSessions(repo.id, worktreeIds) + } + } + } + + /** Browser pages and failed-launch records of a removed project's workspaces on this host. */ + private releaseProjectHostSessions(repoId: string, knownWorktreeIds: readonly string[]): void { + // Why: page records carry no host, so a surviving same-id row's pages are indistinguishable. + if (this.rowsRunHere().some((row) => row.id === repoId)) { + return + } + const pageOwners = [ + ...getRuntimeBrowserPageRegistry(this) + .listPages() + .map((page) => page.workspaceId), + ...listFailedAgentLaunchWorktreeIds(), + ...(this.agentBrowserBridge?.tabWorktreeIds?.() ?? []).map((id) => id ?? '') + ] + const worktreeIds = new Set( + [...knownWorktreeIds, ...pageOwners].filter((id) => getRepoIdFromWorktreeId(id) === repoId) + ) + for (const worktreeId of worktreeIds) { + this.releaseRemovedWorktreeHostSessions(worktreeId, 'project-removal') } } private async stopProjectPtys( repo: Pick, options: { deadline?: number } - ): Promise<{ stopped: number }> { + ): Promise<{ stopped: number; worktreeIds: readonly string[] }> { // Why: after a restart, surviving daemon sessions are in the provider inventory before ptysById. // Bounded so a slow worktree scan cannot spend the budget the known PTYs' stops need. const refreshDeadline = Math.min( @@ -138,16 +168,16 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime removedFences.add(fence.resolvedConnectionId) this.removedProjectFences.set(repo.id, removedFences) if (this.projectSiblingSharesFence(repo)) { - return { stopped: 0 } + return { stopped: 0, worktreeIds: [] } } let stopped = 0 for (const id of listProjectTerminalWorktreeIds(repo.id, this.leaves, this.ptysById)) { projectWorktreeIds.add(id) } for (const id of projectWorktreeIds) { - const opts = { resolvedWorktreeId: id, ...fence, ...options } + const opts = { resolvedWorktreeId: id, ...fence, ...options, requestStopsPastDeadline: true } // Why exclusive: it waits out a spawn that holds the shared lease, so its PTY is registered - // and stopped. Bounded by the deadline so repo.rm still answers; past it the stop is skipped. + // and stopped. Bounded by the deadline so repo.rm still answers; past it PTYs are only asked. // A timed-out waiter is dequeued by the lock, so it cannot hold later spawns back. const release = await this.acquireWorktreeTerminalMutation( id, @@ -165,7 +195,7 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime release?.() } } - return { stopped } + return { stopped, worktreeIds: [...projectWorktreeIds] } } /** @@ -211,12 +241,19 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime return survivors.some((row) => getRepoSshConnectionId(row) === connectionId) } + /** A paired server's row is residue here: its sessions run on that server, never in this process. */ + private rowsRunHere(): Repo[] { + return (this.store?.getRepos?.() ?? []).filter( + (row) => parseExecutionHostId(getRepoExecutionHostId(row))?.kind !== 'runtime' + ) + } + /** A same-id sibling row with the same fence owns sessions this host cannot tell apart. */ private projectSiblingSharesFence( repo: Pick ): boolean { const connectionId = getRepoSshConnectionId(repo) - return (this.store?.getRepos?.() ?? []).some( + return this.rowsRunHere().some( (row) => row.id === repo.id && getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) && diff --git a/src/main/runtime/terminal/stop/worktree-terminal-stop-options.ts b/src/main/runtime/terminal/stop/worktree-terminal-stop-options.ts index 602703295ec9..06278ac45c83 100644 --- a/src/main/runtime/terminal/stop/worktree-terminal-stop-options.ts +++ b/src/main/runtime/terminal/stop/worktree-terminal-stop-options.ts @@ -1,5 +1,8 @@ export type WorktreeTerminalStopOptions = { deadline?: number + /** Past the deadline, still send each remaining PTY a stop without waiting on it. Only for a + * caller giving the PTYs up for good: the stop is recorded and may land after it returns. */ + requestStopsPastDeadline?: boolean stopPty?: ( ptyId: string, stop: () => Promise diff --git a/src/main/runtime/terminal/stop/worktree-terminal-stop.ts b/src/main/runtime/terminal/stop/worktree-terminal-stop.ts index a9bc8732671c..ca993e042ccf 100644 --- a/src/main/runtime/terminal/stop/worktree-terminal-stop.ts +++ b/src/main/runtime/terminal/stop/worktree-terminal-stop.ts @@ -180,7 +180,9 @@ export abstract class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntim ? { id: options.resolvedWorktreeId } : await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) - if (options.deadline !== undefined && Date.now() >= options.deadline) { + const pastDeadline = (): boolean => + options.deadline !== undefined && Date.now() >= options.deadline + if (pastDeadline() && !options.requestStopsPastDeadline) { return { stopped: 0 } } // Preserve folder-instance suffixes while normalizing cross-platform path spelling. @@ -194,11 +196,16 @@ export abstract class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntim let stopped = 0 for (const ptyId of ptyIds) { - if (options.deadline !== undefined && Date.now() >= options.deadline) { - break + if (pastDeadline()) { + if (!options.requestStopsPastDeadline) { + break + } + // Unverified, so never counted: the budget bounds the wait, not who is asked to stop. + this.requestPtyStopWithoutWaiting(ptyId) + continue } const stop = async (): Promise => { - if (options.deadline !== undefined && Date.now() >= options.deadline) { + if (pastDeadline()) { return false } try { @@ -230,6 +237,14 @@ export abstract class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntim return { stopped } } + private requestPtyStopWithoutWaiting(ptyId: string): void { + try { + this.ptyController?.kill(ptyId) + } catch (error) { + console.warn(`[runtime] failed to request a stop for terminal ${ptyId}`, error) + } + } + async sleepTerminalsForWorktree( worktreeSelector: string ): Promise { diff --git a/src/main/runtime/workspaces/managed-workspace-operations.ts b/src/main/runtime/workspaces/managed-workspace-operations.ts index 6c7b4dc592b8..7209202d9287 100644 --- a/src/main/runtime/workspaces/managed-workspace-operations.ts +++ b/src/main/runtime/workspaces/managed-workspace-operations.ts @@ -1,7 +1,10 @@ import type { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '../terminal/launch/terminal-workspace-scope' import { OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce } from '../session-tabs/structured-session-tab-restoration' import { DEFAULT_WORKTREE_LIST_LIMIT } from '../orca-runtime-postlude' -import type { RuntimeWorktreeListResult } from '../../../shared/runtime-types' +import type { + RuntimeWorktreeListResult, + RuntimeWorktreeTerminalSleepResult +} from '../../../shared/runtime-types' import type { DetectedWorktreeListResult, Worktree } from '../../../shared/worktree/types' import type { Repo } from '../../../shared/repo-types' import { stopMissingWorktreeTerminals } from '../missing-worktree-terminal-reconciliation' @@ -29,6 +32,7 @@ import { } from '../../ports/workspace-port-execution-host' import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { teardownStops } from '../owned-session-teardown-policy' export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce { protected abstract resolveTerminalWorkspaceLaunchTarget( @@ -210,12 +214,25 @@ export abstract class OrcaRuntimeWithListManagedWorktrees extends OrcaRuntimeWit ) } + abstract sleepTerminalsForWorktree( + worktreeSelector: string + ): Promise + async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { const worktree = await this.resolveWorktreeSelector(worktreeSelector) // Why: sleep is renderer-initiated on desktop (it tears down tab state // before killing PTYs). The notifier tells the renderer to run its own // sleep flow so all cleanup happens in the correct order. - this.notifier?.sleepWorktree(worktree.id) + if (this.notifier) { + this.notifier.sleepWorktree(worktree.id) + } else if (teardownStops('sleep', 'pty')) { + // No renderer (headless serve, or the window is gone): the host sleeps its own terminals. + const slept = await this.sleepTerminalsForWorktree(worktreeSelector) + if (slept.postStopVerified !== true) { + // An unproven stop is never reported as asleep. + throw new Error(slept.postStopFailure ?? 'terminal_worktree_sleep_unverified') + } + } return { worktreeId: worktree.id } } } diff --git a/src/main/runtime/workspaces/workspace-registration-removal.ts b/src/main/runtime/workspaces/workspace-registration-removal.ts index 0c1a06bf84fb..21c70fe9fd7f 100644 --- a/src/main/runtime/workspaces/workspace-registration-removal.ts +++ b/src/main/runtime/workspaces/workspace-registration-removal.ts @@ -11,6 +11,7 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { getRuntimeFolderWorkspaceRootId } from '../runtime-folder-workspace' import { killAllProcessesForWorktree } from '../worktree-teardown' import { teardownFolderWorkspacePtys } from '../folder-workspace-pty-teardown' +import { teardownStops } from '../owned-session-teardown-policy' type WorkspaceRegistrationRemovalRuntime = WorktreeTeardownRuntime & Pick< @@ -62,7 +63,7 @@ export async function removeOrphanOrFolderWorktree({ onPtyStopped: runtime.onPtyStopped ?? undefined, // A structured session is on no PTY surface, so the sweeps above leave it attached to a // workspace this removal is about to forget. Closed best-effort, exactly like those PTYs. - closeStructuredSessions: true, + closeStructuredSessions: teardownStops('worktree-removal', 'structured-chat'), ...(externalOrphanHost ? { includeProviderInventory: false, includeLocalRegistry: false } : {}) diff --git a/src/main/runtime/workspaces/workspace-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts index 7644cb0e84a4..3ee661ff5ef3 100644 --- a/src/main/runtime/workspaces/workspace-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -13,6 +13,7 @@ import { advertisedUrlWatcher } from '../../ports/advertised-url-watcher' import { deleteWorktreeHistoryDir } from '../../terminal-history-deletion' import { forgetFailedAgentLaunches } from '../../agent-launch/agent-launch-pane-attachment' import { closeClientHostedBrowserPagesForWorktree } from '../worktree-browser-client-page-close' +import { teardownStops } from '../owned-session-teardown-policy' import type { ForceDeleteWorktreeBranchResult, RemoveWorktreeResult @@ -188,23 +189,28 @@ export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRu this.cancelScheduledMobileSessionTabsChanged(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) - forgetFailedAgentLaunches(worktreeId, null) deleteWorktreeHistoryDir(worktreeId) - this.closeHeadlessBrowserPagesForWorktree(worktreeId) - closeClientHostedBrowserPagesForWorktree(this, worktreeId) + this.releaseRemovedWorktreeHostSessions(worktreeId, 'worktree-removal') } } - // Why: headless offscreen browser pages are main-process BrowserWindows that - // outlive a worktree unless explicitly closed โ€” removing a worktree without - // closing its open panes leaks the windows for the life of the serve process. - protected closeHeadlessBrowserPagesForWorktree(worktreeId: string): void { - if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { + // Every removal path calls this (desktop IPC, runtime, project); PTYs and chats stop before. + // Headless pages are main-process windows that otherwise outlive the worktree. + releaseRemovedWorktreeHostSessions( + worktreeId: string, + entryPoint: 'worktree-removal' | 'folder-removal' | 'project-removal' + ): void { + if (teardownStops(entryPoint, 'launch')) { + forgetFailedAgentLaunches(worktreeId, null) + } + if (!teardownStops(entryPoint, 'host-browser')) { return } - for (const tab of this.agentBrowserBridge.tabList(worktreeId).tabs) { - void this.offscreenBrowserBackend.closeTab(tab.browserPageId).catch(() => {}) + const backend = this.offscreenBrowserBackend + for (const tab of backend ? (this.agentBrowserBridge?.tabList?.(worktreeId).tabs ?? []) : []) { + void backend?.closeTab(tab.browserPageId).catch(() => {}) } + closeClientHostedBrowserPagesForWorktree(this, worktreeId) } async forceDeletePreservedBranch( diff --git a/src/main/speech/speech-model-download-transport.ts b/src/main/speech/speech-model-download-transport.ts index 1d887a1295e2..1942f5d762e0 100644 --- a/src/main/speech/speech-model-download-transport.ts +++ b/src/main/speech/speech-model-download-transport.ts @@ -148,7 +148,7 @@ export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownlo cleanup() reject(error) } - const onData = (chunk: Buffer): void => { + const onData = (chunk: string | Buffer): void => { hash.update(chunk) } const onError = (error: Error): void => { diff --git a/src/main/ssh-expired-lease-pane-readoption.test.ts b/src/main/ssh-expired-lease-pane-readoption.test.ts index a4d4de27c0b5..7376a31ad96b 100644 --- a/src/main/ssh-expired-lease-pane-readoption.test.ts +++ b/src/main/ssh-expired-lease-pane-readoption.test.ts @@ -12,6 +12,7 @@ import { makeTerminalTab } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { PtyExitProof } from './pty-exit-proof' vi.mock('electron', () => ({ app: { getPath: () => testState.dir }, @@ -87,7 +88,7 @@ describe('a pane whose SSH lease expired can still be re-adopted', () => { it('drops the persisted owner after the lease is terminated', () => { const store = storeWithBoundRemotePane() - store.markSshRemotePtyLease(TARGET, APP_PTY_ID, 'terminated') + store.markSshRemotePtyLease(TARGET, APP_PTY_ID, PtyExitProof.from('exit-observed')) expect( resolvePersistedStablePaneOwner(store, makePaneKey(TAB, TEST_LEAF_1), WORKTREE, TARGET) diff --git a/src/main/ssh/connection/ssh-connection-store.ts b/src/main/ssh/connection/ssh-connection-store.ts index fa5bd340041a..36cba4bd26a5 100644 --- a/src/main/ssh/connection/ssh-connection-store.ts +++ b/src/main/ssh/connection/ssh-connection-store.ts @@ -265,7 +265,7 @@ export function getRuntimeOwnedSshTargetId(runtimeId: string): string { return `${RUNTIME_OWNED_SSH_TARGET_ID_PREFIX}${runtimeId}` } -/** Ephemeral runtime targets, hidden from SSH-host surfaces. */ +/** Any target Orca created as an implementation detail; hidden from SSH-host surfaces. */ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner !== undefined } diff --git a/src/main/ssh/connection/ssh-connection.ts b/src/main/ssh/connection/ssh-connection.ts index 5532e128c528..787475f38538 100644 --- a/src/main/ssh/connection/ssh-connection.ts +++ b/src/main/ssh/connection/ssh-connection.ts @@ -57,7 +57,11 @@ import { DEFAULT_SERVER_HOST_KEY_ALGORITHMS, orderServerHostKeyAlgorithms } from '../ssh-host-key-verifier' -import { HostKeyVerificationError, isHostKeyVerificationError } from '../ssh-host-key-decision' +import { + HostKeyVerificationError, + hostKeyPolicyForOwner, + isHostKeyVerificationError +} from '../ssh-host-key-decision' import { resolveWithSshG, siteConfigMayRestrictHostKeys, @@ -95,7 +99,6 @@ import type { FileUploadSession } from '../../providers/filesystem-provider-cont import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from '../ssh-channel-open' import { tagSftpHandshakeCorruption } from '../sftp-handshake-corruption' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { isEphemeralRuntimeSshOwner } from '../../../shared/managed-orcad-ssh-owner' import { isSshUserDisconnected } from '../ssh-user-disconnect' /** The sign-in a connection holds in memory, handed to the connection that replaces it. */ @@ -1230,7 +1233,7 @@ export class SshConnection { hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined, strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask', isHostKeyAlias, - isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner), + policy: hostKeyPolicyForOwner(this.target.owner), siteConfigSuppressed, // A file that EXISTS and will not open is the absence of evidence, not evidence of a new // host โ€” the entry that would have said "this key changed" may be in it. An ABSENT file is diff --git a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-cells.ts b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-cells.ts index a292385a503a..55d58500512d 100644 --- a/src/main/ssh/hostile-host-test-support/ssh-hostile-host-cells.ts +++ b/src/main/ssh/hostile-host-test-support/ssh-hostile-host-cells.ts @@ -123,7 +123,7 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ ' && rm -rf /var/lib/apt/lists/*' ], homeNoexec: true, - managed: { outcome: 'unavailable', runtime: 'linux-x64-glibc', reason: 'runtime_self_test' } + managed: { outcome: 'unavailable', runtime: 'linux-x64-glibc', reason: 'runtime_noexec' } }, { // glibc 2.17 is below the pinned Node's floor, so orcad runs on the glibc 2.17 compat runtime. diff --git a/src/main/ssh/legacy-relay/README.md b/src/main/ssh/legacy-relay/README.md index 81d51d14eedc..6543a51c9439 100644 --- a/src/main/ssh/legacy-relay/README.md +++ b/src/main/ssh/legacy-relay/README.md @@ -19,6 +19,12 @@ nobody is prompted. - A dropped connection, or a disconnect that hangs the bridge up, is `unverifiable` and reattaches later; removing the target is what empties its panes. A pane retires (lease `terminated`) only when its relay reports the exit, or every relay for the target answered without it. +- Closing a bridged tab, by any stop route, sends the relay's own `pty.shutdown`. A stop the relay + can't take yet is kept on the lease as `pendingKill`, so it survives a restart; that lease is + listed for the stop but never served as a pane again. A stop no later run could fence to the same + process (a legacy `pty-N` id with no incarnation) gets one try, then the lease ends. +- Sleeping a bridged pane is refused (the pane stays live): its stop would be durable, so it would + outlive the sleep. A stop-and-wait caller succeeds only on the relay's exit proof. **Touches** `ipc/ssh.ts`, `ipc/ssh-connect-flow.ts`, `ipc/ssh-host-server-on-connect-wiring.ts`, `ipc/ssh-session-teardown.ts`, the conversion gate's `bridged` verdict diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-ipc.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-ipc.ts index 6093e52b62d8..f602814f797b 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-bridge-ipc.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-ipc.ts @@ -8,6 +8,8 @@ import { LEGACY_RELAY_BRIDGE_CHANNELS as CHANNELS } from '../../../shared/legacy import { isRemoteCliControlAllowed } from '../../../shared/ssh-remote-cli-control' import type { Store } from '../../persistence' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import { PtyRetirementWithoutProof } from '../../pty-exit-proof' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import { track } from '../../telemetry/client' import type { SshConnection } from '../connection/ssh-connection' import { knownSshHostPlatform } from '../ssh-host-platform-memo' @@ -20,9 +22,9 @@ import { legacyRelayBridgedPanes, resizeLegacyRelayPane, setLegacyRelayPaneOutputPaused, - terminalsBucket, writeLegacyRelayPane } from './legacy-relay-bridge-registry' +import { terminalsBucket } from './legacy-relay-daily-activity' const PtyIdSchema = z.string().min(1).max(4096) const SizeSchema = z.number().int().positive().max(10_000) @@ -34,7 +36,13 @@ const PausedArgsSchema = z.object({ ptyId: PtyIdSchema, paused: z.boolean() }) const ResizeArgsSchema = z.object({ ptyId: PtyIdSchema, cols: SizeSchema, rows: SizeSchema }) export function registerLegacyRelayBridgeIpc(args: { - store: Pick + store: Pick< + Store, + | 'getSshRemotePtyLeases' + | 'markSshRemotePtyLease' + | 'setSshRemotePtyLeasePendingKill' + | 'getSshTarget' + > getMainWindow: () => BrowserWindow | null connection: (targetId: string) => SshConnection | null runtime?: () => OrcaRuntimeService | null @@ -47,8 +55,21 @@ export function registerLegacyRelayBridgeIpc(args: { } installLegacyRelayBridge({ leases: (targetId) => args.store.getSshRemotePtyLeases(targetId), - retireLease: (targetId, relayPtyId) => - args.store.markSshRemotePtyLease(targetId, relayPtyId, 'terminated'), + retireLease: (targetId, relayPtyId, evidence) => { + // Cleared first so the retire can prune the tombstone. + args.store.setSshRemotePtyLeasePendingKill(targetId, relayPtyId, null) + args.store.markSshRemotePtyLease(targetId, relayPtyId, evidence) + if (evidence instanceof PtyRetirementWithoutProof) { + args + .runtime?.() + ?.markPtyLivenessUnverifiable( + toAppSshPtyId(targetId, relayPtyId), + evidence.unverifiableReason + ) + } + }, + recordStop: (targetId, relayPtyId, pendingKill) => + args.store.setSshRemotePtyLeasePendingKill(targetId, relayPtyId, pendingKill), connection: args.connection, ...(args.runtime ? { diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-leases.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-leases.ts index 12fa0178fc85..31c7f95b607e 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-bridge-leases.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-leases.ts @@ -15,35 +15,59 @@ export const ATTACH_WAIT_MS = 20_000 /** The one attach refusal backed by the relay's own liveness check (1.4.2xx relays). */ export const PROVEN_EXITED = /PTY ".+" not found \(process exited\)/i -/** - * One a relay-era pane still names and nobody closed: a lease with a stop still pending, or one a - * newer pane won, was meant to go away and is never brought back. - */ -export function isBridgeableLease(lease: SshRemotePtyLease): boolean { +/** A relay-era pane's lease whose shell may still run; one a newer pane won is never brought back. */ +function isRelayEraPaneLease(lease: SshRemotePtyLease): boolean { return ( lease.state !== 'terminated' && typeof lease.tabId === 'string' && lease.tabId.length > 0 && - !lease.pendingKill && !lease.supersededBy && !lease.relayIdRecycled ) } +/** One a relay-era pane still names and nobody closed. */ +export function isBridgeableLease(lease: SshRemotePtyLease): boolean { + return isRelayEraPaneLease(lease) && !lease.pendingKill +} + +/** + * One whose tab closed before its relay confirmed the stop. The bridge still lists its relay to + * send the stop, including after a restart, but never serves it as a pane again. + */ +export function isStoppingLease(lease: SshRemotePtyLease): boolean { + return isRelayEraPaneLease(lease) && Boolean(lease.pendingKill) +} + export function bridgeableLeasesByRelayId( leases: readonly SshRemotePtyLease[] ): Map { return new Map(leases.filter(isBridgeableLease).map((lease) => [lease.ptyId, lease])) } -export function leasedAppPtyIds(targetId: string, leases: SshRemotePtyLease[]): Set { - return new Set( - [...bridgeableLeasesByRelayId(leases).keys()].map((relayPtyId) => - toAppSshPtyId(targetId, relayPtyId) - ) +/** Every lease the bridge lists a relay for: the panes it serves and the stops it owes. */ +export function listedLeasesByRelayId( + leases: readonly SshRemotePtyLease[] +): Map { + return new Map( + leases + .filter((lease) => isBridgeableLease(lease) || isStoppingLease(lease)) + .map((lease) => [lease.ptyId, lease]) ) } +export function leasedAppPtyIds(targetId: string, leases: SshRemotePtyLease[]): Set { + return toAppPtyIds(targetId, bridgeableLeasesByRelayId(leases)) +} + +export function listedAppPtyIds(targetId: string, leases: SshRemotePtyLease[]): Set { + return toAppPtyIds(targetId, listedLeasesByRelayId(leases)) +} + +function toAppPtyIds(targetId: string, leases: Map): Set { + return new Set([...leases.keys()].map((relayPtyId) => toAppSshPtyId(targetId, relayPtyId))) +} + /** One leased pane the bridge tracks, and the relay endpoint that listed it. */ export type Pane = { lease: SshRemotePtyLease @@ -52,6 +76,37 @@ export type Pane = { state: 'live' | 'unverifiable' /** What the listing relay claimed about the process, for the relay-era CLI's identity checks. */ listed?: LegacyRelayPty + /** Its tab closed and its stop is still owed: listed for the stop, never served. */ + stopping?: true +} + +export function toPane( + lease: SshRemotePtyLease, + relayPtyId: string, + endpoint: string | null, + state: Pane['state'], + listed?: LegacyRelayPty +): Pane { + return { + lease, + relayPtyId, + endpoint, + state, + ...(listed ? { listed } : {}), + ...(lease.pendingKill ? { stopping: true as const } : {}) + } +} + +/** The panes a bridge serves; a closed tab's pane, its stop owed or its lease ended, is not one. */ +export function servedBridgedPanes( + targetId: string, + panes: ReadonlyMap, + leases: readonly SshRemotePtyLease[] +): LegacyRelayBridgedPane[] { + const served = bridgeableLeasesByRelayId(leases) + return [...panes] + .filter(([, pane]) => !pane.stopping && served.has(pane.relayPtyId)) + .map(([ptyId, pane]) => toBridgedPane(targetId, ptyId, pane)) } export type LegacyRelayLivePane = { ptyId: string; incarnationId?: string; terminalHandle?: string } @@ -59,15 +114,18 @@ export type LegacyRelayLivePane = { ptyId: string; incarnationId?: string; termi /** The live leased pane a pane key's stable leaf names; the lease is the desktop's own record. */ export function findLivePane( panes: ReadonlyMap, - paneKey: string + paneKey: string, + leases: readonly SshRemotePtyLease[] ): LegacyRelayLivePane | null { const parsed = parsePaneKey(paneKey) if (!parsed) { return null } + const served = bridgeableLeasesByRelayId(leases) for (const [ptyId, pane] of panes) { // Why the leaf alone: a moved pane's lease takes its new tab, but its shell keeps the old key. - if (pane.state === 'live' && pane.lease.leafId === parsed.leafId) { + const named = pane.lease.leafId === parsed.leafId && served.has(pane.relayPtyId) + if (pane.state === 'live' && !pane.stopping && named) { const { incarnationId, terminalHandle } = pane.listed ?? {} return { ptyId, @@ -79,7 +137,7 @@ export function findLivePane( return null } -export function toBridgedPane(targetId: string, ptyId: string, pane: Pane): LegacyRelayBridgedPane { +function toBridgedPane(targetId: string, ptyId: string, pane: Pane): LegacyRelayBridgedPane { return { ptyId, targetId, diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-registry.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-registry.ts index 355cfc37197c..5f11ef68beb0 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-bridge-registry.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-registry.ts @@ -9,15 +9,14 @@ import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import type { SshConnection } from '../connection/ssh-connection' import { waitForSentinel } from './ssh-relay-sentinel' -import { probeRelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' -import { isWindowsRemoteHost } from '../ssh-remote-platform' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { LegacyRelayClient } from './legacy-relay-client' import { createLegacyRelayCliServer } from './legacy-relay-cli-server' -import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints' +import { listLegacyRelayEndpoints, posixEndpointExited } from './legacy-relay-endpoints' import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' import { LegacyRelayHiddenOutput } from './legacy-relay-hidden-output' import { LegacyRelayPaneStops } from './legacy-relay-pane-stops' @@ -25,20 +24,24 @@ import { reportDailyRelayActivity, watchDailyRelayActivity } from './legacy-rela import { bridgeableLeasesByRelayId, isBridgeableLease, - leasedAppPtyIds + listedAppPtyIds, + listedLeasesByRelayId } from './legacy-relay-bridge-leases' +import { PtyExitProof, type PtyRetirementEvidence } from '../../pty-exit-proof' /** How long a connect waits for the first look at its relays before publishing the host. */ const START_BUDGET_MS = 20_000 /** How long an attach waits for a target whose connection is still coming up. */ const CONNECTION_WAIT_MS = 20_000 -type TerminalsBucket = EventProps<'ssh_host_server_legacy_relay_bridge'>['terminals'] type ReportPhase = EventProps<'ssh_host_server_legacy_relay_bridge'>['phase'] export type LegacyRelayBridgeHost = { leases: (targetId: string) => SshRemotePtyLease[] - retireLease: (targetId: string, relayPtyId: string) => void + /** Ends the lease and any stop recorded on it: on the relay's proof, or on a stop it can't be sent. */ + retireLease: (targetId: string, relayPtyId: string, evidence: PtyRetirementEvidence) => void + /** Persists a closed tab's stop on its lease, so a restart still sends it. */ + recordStop: (targetId: string, relayPtyId: string, pendingKill: SshPendingPtyKill) => void connection: (targetId: string) => SshConnection | null onPanesChanged: (panes: LegacyRelayBridgedPane[]) => void onData: (ptyId: string, data: string) => void @@ -84,21 +87,9 @@ export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): vo : null } -export function terminalsBucket(count: number): TerminalsBucket { - if (count <= 0) { - return 'none' - } - if (count === 1) { - return '1' - } - if (count < 5) { - return '2_4' - } - return count < 10 ? '5_9' : '10_plus' -} - -function hasBridgeableLeases(targetId: string): boolean { - return host?.leases(targetId).some(isBridgeableLease) ?? false +/** Panes it serves, or closed tabs' stops it still owes. */ +function hasListedLeases(targetId: string): boolean { + return listedLeasesByRelayId(host?.leases(targetId) ?? []).size > 0 } function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { @@ -127,7 +118,8 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { const bridge: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({ targetId, leases: () => installed.leases(targetId), - retireLease: (relayPtyId) => installed.retireLease(targetId, relayPtyId), + retireLease: (relayPtyId, proof) => + installed.retireLease(targetId, relayPtyId, PtyExitProof.from(proof)), listEndpoints: async (signal) => { const conn = installed.connection(targetId) return conn ? listLegacyRelayEndpoints(conn, targetId, signal) : { kind: 'unverifiable' } @@ -157,6 +149,7 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { onData: (ptyId, data) => hiddenOutput?.onData(ptyId, data), onExit: (ptyId, code) => { hiddenOutput?.forget(ptyId) + stopsFor(targetId).exited(ptyId) installed.onExit(ptyId, code) }, onTerminalExited: () => installed.report(targetId, 'terminal_exited', 0) @@ -170,34 +163,17 @@ function stopsFor(targetId: string): LegacyRelayPaneStops { if (existing) { return existing } - const created = new LegacyRelayPaneStops(targetId, () => host?.leases(targetId) ?? []) + const created = new LegacyRelayPaneStops(targetId, () => host) paneStops.set(targetId, created) return created } -/** A dead socket is the one failed open that proves its relay gone; Windows pipes never are. */ -async function posixEndpointExited( - conn: SshConnection, - endpoint: LegacyRelayEndpoint -): Promise { - if (isWindowsRemoteHost(endpoint.host) || !endpoint.nodePath) { - return false - } - const incumbent = await probeRelayEndpointIncumbent( - conn, - endpoint.host, - endpoint.nodePath, - endpoint.endpoint - ) - return incumbent.verdict === 'exited' -} - /** * Called once a connect reaches the host's managed server: looks at the relays this target left, * so the panes they still run are known before the renderer remounts them on the new server. */ export async function startLegacyRelayBridge(targetId: string): Promise { - if (!hasBridgeableLeases(targetId)) { + if (!hasListedLeases(targetId)) { // Leases retired while hung up publish nothing themselves; this connect is the next answer. releaseHeldPanes(targetId) return @@ -266,7 +242,9 @@ export async function attachLegacyRelayPane( replayInStream = false ): Promise { const targetId = parseAppSshPtyId(ptyId)?.connectionId - if (!targetId || !host || !hasBridgeableLeases(targetId)) { + const serves = targetId ? host?.leases(targetId).some(isBridgeableLease) : false + // A closed tab's pane whose stop is owed is never served again. + if (!targetId || !serves || stopsFor(targetId).owes(ptyId)) { return { status: 'not-bridged' } } const bridge = bridgeFor(targetId) @@ -329,22 +307,33 @@ export function setLegacyRelayPaneOutputPaused(ptyId: string, paused: boolean): hiddenOutput?.setPaused(ptyId, paused) } -/** Whether this PTY is an older relay's terminal the bridge serves, so only the bridge stops it. */ +/** Whether this PTY is an older relay's terminal the bridge serves or owes a stop, so only the + * bridge stops it. */ export function isLegacyRelayBridgedPty(ptyId: string): boolean { const targetId = parseAppSshPtyId(ptyId)?.connectionId - return Boolean(targetId && host && leasedAppPtyIds(targetId, host.leases(targetId)).has(ptyId)) + return Boolean(targetId && host && listedAppPtyIds(targetId, host.leases(targetId)).has(ptyId)) } /** Its tab closed: the relay stops the shell, or is asked once it can be reached. */ export async function closeLegacyRelayPane(ptyId: string): Promise { const targetId = parseAppSshPtyId(ptyId)?.connectionId - if (!targetId || !host || !hasBridgeableLeases(targetId)) { + if (!targetId || !host || !hasListedLeases(targetId)) { return { status: 'not-bridged' } } // Disconnected: recorded now, and the target's next connect lists the relay and sends the stop. return stopsFor(targetId).stop(ptyId, host.connection(targetId) ? bridgeFor(targetId) : null) } +/** A close that settles only on the relay's exit proof, by the deadline. */ +export async function stopLegacyRelayPaneAndWait( + ptyId: string, + deadlineMs: number +): Promise { + const targetId = parseAppSshPtyId(ptyId)?.connectionId + const bridge = targetId && host?.connection(targetId) ? bridgeFor(targetId) : null + return targetId && host ? stopsFor(targetId).stopAndWait(ptyId, bridge, deadlineMs) : false +} + export function detachLegacyRelayPane(ptyId: string, acquisitionId: number): void { bridgeForPty(ptyId)?.detach(ptyId, acquisitionId) } diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-restart-stop.test.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-restart-stop.test.ts new file mode 100644 index 000000000000..c1ba410516d3 --- /dev/null +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-restart-stop.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import type { SshConnection } from '../connection/ssh-connection' +import { getRemoteHostPlatform } from '../ssh-remote-platform' + +const TARGET = 'ssh-box' +const RELAY_PTY = 'pty2:epoch:1' +const PTY = toAppSshPtyId(TARGET, RELAY_PTY) + +const relay = vi.hoisted(() => { + const shutdowns: string[] = [] + return { ptys: new Set(), shutdowns } +}) + +vi.mock('./legacy-relay-endpoints', () => ({ + listLegacyRelayEndpoints: async () => ({ + kind: 'listed', + exhaustive: true, + endpoints: [ + { + endpoint: '/home/me/.orca-remote/relay-x.sock', + connectCommand: 'node relay.js --connect', + wrapCommand: true, + host: getRemoteHostPlatform('linux-x64'), + nodePath: 'node' + } + ] + }) +})) + +vi.mock('./legacy-relay-client', () => ({ + LegacyRelayClient: { + open: async () => { + const exits = new Set<(id: string, code: number) => void>() + return { + ownerClaim: null, + closed: false, + list: async () => [...relay.ptys].map((relayPtyId) => ({ relayPtyId })), + shutdown: async (id: string) => { + relay.shutdowns.push(id) + relay.ptys.delete(id) + exits.forEach((listener) => listener(id, 0)) + }, + onData: () => () => {}, + onExit: (listener: (id: string, code: number) => void) => { + exits.add(listener) + return () => exits.delete(listener) + }, + onClose: () => () => {}, + close: () => {} + } + } + } +})) + +const registry = await import('./legacy-relay-bridge-registry') + +/** The persisted leases, which are all a restart keeps. */ +let leases: SshRemotePtyLease[] +let connected: boolean + +function installHost(): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked endpoints and client never touch the connection. + const connection = {} as SshConnection + registry.installLegacyRelayBridge({ + leases: () => leases, + retireLease: (_target, relayPtyId) => { + leases = leases.map((row) => { + if (row.ptyId !== relayPtyId) { + return row + } + const { pendingKill: _delivered, ...rest } = row + return { ...rest, state: 'terminated' } + }) + }, + recordStop: (_target, relayPtyId, pendingKill) => { + leases = leases.map((row) => { + if (row.ptyId !== relayPtyId) { + return row + } + const { pendingKill: _dropped, ...rest } = row + return pendingKill ? { ...rest, pendingKill } : rest + }) + }, + connection: () => (connected ? connection : null), + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) +} + +afterEach(() => { + registry.installLegacyRelayBridge(null) +}) + +describe('a bridged tab closed while disconnected', () => { + it('still stops its shell when Orca restarts before reconnecting', async () => { + relay.ptys = new Set([RELAY_PTY]) + relay.shutdowns = [] + leases = [ + { + targetId: TARGET, + ptyId: RELAY_PTY, + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + connected = false + installHost() + await expect(registry.closeLegacyRelayPane(PTY)).resolves.toEqual({ status: 'unverifiable' }) + + // Restart, then reconnect. + installHost() + await expect(registry.attachLegacyRelayPane(PTY)).resolves.toEqual({ status: 'not-bridged' }) + connected = true + await registry.startLegacyRelayBridge(TARGET) + + expect(relay.shutdowns).toEqual([RELAY_PTY]) + expect(registry.legacyRelayBridgedPanes()).toEqual([]) + expect(leases[0].state).toBe('terminated') + expect(leases[0].pendingKill).toBeUndefined() + }) +}) + +describe('a stop-and-wait of a connected bridged tab', () => { + it('settles true only on the relayโ€™s exit report', async () => { + relay.ptys = new Set([RELAY_PTY]) + relay.shutdowns = [] + leases = [ + { + targetId: TARGET, + ptyId: RELAY_PTY, + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + connected = true + installHost() + await registry.startLegacyRelayBridge(TARGET) + await expect(registry.stopLegacyRelayPaneAndWait(PTY, Date.now() + 5_000)).resolves.toBe(true) + expect(relay.shutdowns).toEqual([RELAY_PTY]) + expect(leases[0].state).toBe('terminated') + }) +}) diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-teardown.test.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-teardown.test.ts index f4d6b5291a96..bcc86aa430e0 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-bridge-teardown.test.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-teardown.test.ts @@ -82,6 +82,7 @@ beforeEach(async () => { registry.installLegacyRelayBridge({ leases: () => leases, retireLease: vi.fn(), + recordStop: vi.fn(), connection: () => null, // Main publishes to the window as legacy-relay-bridge-ipc.ts does. onPanesChanged: (panes) => renderer.listeners.forEach((listener) => listener(panes)), @@ -156,6 +157,7 @@ describe('attaching a bridged pane', () => { registry.installLegacyRelayBridge({ leases: () => leases, retireLease: vi.fn(), + recordStop: vi.fn(), connection: () => connection, onPanesChanged: vi.fn(), onData: vi.fn(), diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.test.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.test.ts new file mode 100644 index 000000000000..ab3baa81c045 --- /dev/null +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { LegacyRelayExitProofs } from './legacy-relay-bridge-timers' + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('waiting on a bridged paneโ€™s exit proof', () => { + it('settles every overlapping waiter on one exit', async () => { + const proofs = new LegacyRelayExitProofs() + const first = proofs.wait('pty', 5_000) + const second = proofs.wait('pty', 5_000) + proofs.observe('pty') + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + }) + + it('never lets an earlier waiterโ€™s deadline drop a later one', async () => { + const proofs = new LegacyRelayExitProofs() + const stale = proofs.wait('pty', 1_000) + await vi.advanceTimersByTimeAsync(500) + const retry = proofs.wait('pty', 5_000) + await vi.advanceTimersByTimeAsync(600) + await expect(stale).resolves.toBe(false) + proofs.observe('pty') + await expect(retry).resolves.toBe(true) + }) + + it('answers false on dispose', async () => { + const proofs = new LegacyRelayExitProofs() + const waiting = proofs.wait('pty', 5_000) + proofs.dispose() + await expect(waiting).resolves.toBe(false) + }) +}) diff --git a/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.ts b/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.ts index 78bfcb6cd5f4..c3750e57eb62 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-bridge-timers.ts @@ -64,3 +64,40 @@ export class LegacyRelayWaiters { } } } + +/** Stops awaiting the relay's exit proof (its report, or a complete listing without the pane). */ +export class LegacyRelayExitProofs { + private readonly waiting = new Map void>>() + + /** Each caller settles on its own: true on the exit, false at its deadline or on dispose. */ + wait(ptyId: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const waiters = this.waiting.get(ptyId) ?? new Set() + this.waiting.set(ptyId, waiters) + const settle = (exited: boolean): void => { + clearTimeout(timer) + waiters.delete(settle) + if (waiters.size === 0 && this.waiting.get(ptyId) === waiters) { + this.waiting.delete(ptyId) + } + resolve(exited) + } + const timer = setTimeout(() => settle(false), timeoutMs) + waiters.add(settle) + }) + } + + observe(ptyId: string): void { + for (const settle of Array.from(this.waiting.get(ptyId) ?? [])) { + settle(true) + } + } + + dispose(): void { + for (const ptyId of Array.from(this.waiting.keys())) { + for (const settle of Array.from(this.waiting.get(ptyId) ?? [])) { + settle(false) + } + } + } +} diff --git a/src/main/ssh/legacy-relay/legacy-relay-daily-activity.ts b/src/main/ssh/legacy-relay/legacy-relay-daily-activity.ts index 5677d526d122..44b6ab813460 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-daily-activity.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-daily-activity.ts @@ -2,6 +2,9 @@ * One `active` telemetry row per target per UTC day while it bridges relay terminals, so the * relay's retirement can see who still uses it. COMPAT(legacy-relay-bridge): revisit 2027-01-15. */ +import type { EventProps } from '../../../shared/telemetry-events' + +type TerminalsBucket = EventProps<'ssh_host_server_legacy_relay_bridge'>['terminals'] type ActivityReport = (targetId: string, phase: 'active', terminals: number) => void /** Why a timer: a terminal left attached across midnight changes no pane and attaches nothing. */ @@ -43,3 +46,16 @@ export function watchDailyRelayActivity( }, CHECK_INTERVAL_MS) timer.unref?.() } + +export function terminalsBucket(count: number): TerminalsBucket { + if (count <= 0) { + return 'none' + } + if (count === 1) { + return '1' + } + if (count < 5) { + return '2_4' + } + return count < 10 ? '5_9' : '10_plus' +} diff --git a/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts b/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts index 96263f12bfa2..2011aa775b7e 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-endpoints.ts @@ -12,6 +12,7 @@ import { SHORT_RELAY_SOCKET_DIR_PREFIX } from '../relay-socket-path-limit' import { execCommand } from '../remote-exec/remote-exec-command' import { readRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' +import { probeRelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' import { readRemoteHomeCommand } from '../ssh-remote-commands' import { resolveRemoteNodePath } from '../ssh-remote-node-resolution' import { detectRemoteHostPlatform } from '../ssh-remote-platform-detection' @@ -209,3 +210,20 @@ async function listWindowsEndpoints( } return { kind: 'listed', endpoints, exhaustive } } + +/** A dead socket is the one failed open that proves its relay gone; Windows pipes never are. */ +export async function posixEndpointExited( + conn: SshConnection, + endpoint: LegacyRelayEndpoint +): Promise { + if (isWindowsRemoteHost(endpoint.host) || !endpoint.nodePath) { + return false + } + const incumbent = await probeRelayEndpointIncumbent( + conn, + endpoint.host, + endpoint.nodePath, + endpoint.endpoint + ) + return incumbent.verdict === 'exited' +} diff --git a/src/main/ssh/legacy-relay/legacy-relay-pane-stops.ts b/src/main/ssh/legacy-relay/legacy-relay-pane-stops.ts index 77d15a3169b8..e0459d1d9e62 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-pane-stops.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-pane-stops.ts @@ -5,17 +5,25 @@ * Held outside the bridge, so a reconnect's new bridge still sends them. A stop is pending until * the relay reports the exit or a listing proves the terminal gone; while the relay can't be asked * the answer is `unverifiable` and the stop goes out once it can. Loss of contact is never exit. + * + * A stop whose process can be named after a restart is persisted as the lease's `pendingKill`, the + * relay-era record of the same intent, so closing a tab and quitting before reconnecting still + * stops the shell. The relay connection is the other proof of identity, and it does not survive. */ +import { parseAppSshPtyId, toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshPendingPtyKill } from '../../../shared/ssh-pending-pty-kill' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import type { LegacyRelayBridgeCloseResult } from '../../../shared/legacy-relay-bridge-contract' -import { leasedAppPtyIds } from './legacy-relay-bridge-leases' -import { LegacyRelayRetry } from './legacy-relay-bridge-timers' +import { isStoppingLease, listedAppPtyIds } from './legacy-relay-bridge-leases' +import { LegacyRelayExitProofs, LegacyRelayRetry } from './legacy-relay-bridge-timers' import type { LegacyRelayClient } from './legacy-relay-client' +import type { LegacyRelayBridgeHost } from './legacy-relay-bridge-registry' import type { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' +import { PtyRetirementWithoutProof } from '../../pty-exit-proof' type StopTarget = Pick< LegacyRelayTargetBridge, - 'snapshot' | 'liveRoute' | 'listedIncarnation' | 'refresh' + 'paneState' | 'liveRoute' | 'listedIncarnation' | 'refresh' > /** @@ -24,31 +32,55 @@ type StopTarget = Pick< */ type PendingStop = { incarnationId?: string; client?: LegacyRelayClient } +/** How long a sent unfenced stop keeps its pane tracked for the relay's exit report. */ +const UNFENCED_EXIT_WAIT_MS = 5_000 + export class LegacyRelayPaneStops { private readonly closing = new Map() private bridge: StopTarget | null = null private readonly relist = new LegacyRelayRetry(() => void this.bridge?.refresh()) + private readonly exits = new LegacyRelayExitProofs() constructor( private readonly targetId: string, - private readonly leases: () => SshRemotePtyLease[] + private readonly host: () => Pick< + LegacyRelayBridgeHost, + 'leases' | 'recordStop' | 'retireLease' + > | null ) {} + private leases(): SshRemotePtyLease[] { + return this.host()?.leases(this.targetId) ?? [] + } + + private persist(relayPtyId: string, pendingKill: SshPendingPtyKill): void { + this.host()?.recordStop(this.targetId, relayPtyId, pendingKill) + } + /** `bridge` is null while the target is disconnected; its next listing sends the stop. */ async stop(ptyId: string, bridge: StopTarget | null): Promise { - const pane = bridge?.snapshot().find((entry) => entry.ptyId === ptyId) - if (!pane && !leasedAppPtyIds(this.targetId, this.leases()).has(ptyId)) { + const state = bridge?.paneState(ptyId) + if (!state && !listedAppPtyIds(this.targetId, this.leases()).has(ptyId)) { return { status: 'exited' } } - // A repeated close while the relay can't be asked keeps the identity the first one saw. - const earlier = this.closing.get(ptyId) - const incarnationId = bridge?.listedIncarnation(ptyId) ?? earlier?.incarnationId - const client = bridge?.liveRoute(ptyId)?.client ?? earlier?.client + // A repeated close keeps the identity the first one saw; the id may now name another process. + const earlier = this.pending(ptyId) + const incarnationId = earlier ? earlier.incarnationId : bridge?.listedIncarnation(ptyId) + const client = earlier ? earlier.client : bridge?.liveRoute(ptyId)?.client this.closing.set(ptyId, { ...(incarnationId ? { incarnationId } : {}), ...(client ? { client } : {}) }) - if (!bridge || pane?.state !== 'live') { + if (!this.record(ptyId, incarnationId)) { + // Unfenced: only this connection can prove the process, and a restart would forget the close, + // so it gets one try now and the lease ends as any closed tab's does. + const attempted = Boolean(bridge) && state === 'live' + const sent = bridge && attempted ? await this.send(ptyId, bridge) : false + // A failed reply may still have stopped it, so its exit report gets the same chance. + this.abandon(ptyId, bridge, attempted) + return sent ? { status: 'requested' } : { status: 'unverifiable' } + } + if (!bridge || state !== 'live') { return { status: 'unverifiable' } } const sent = await this.send(ptyId, bridge) @@ -57,22 +89,44 @@ export class LegacyRelayPaneStops { return sent ? { status: 'requested' } : { status: 'unverifiable' } } + /** True only once the relay proves the exit (its report, or a complete listing without it). */ + async stopAndWait( + ptyId: string, + bridge: StopTarget | null, + deadlineMs: number + ): Promise { + // Armed before the stop is sent, so an exit that beats its reply, or a lost reply, still counts. + const exited = this.exits.wait(ptyId, Math.max(1, deadlineMs - Date.now())) + // A stop sent to a live pane may still prove out after a failed reply; an unsent one cannot. + const sendable = bridge?.paneState(ptyId) === 'live' + const stopped = this.stop(ptyId, bridge).then((result) => + result.status === 'exited' ? true : result.status === 'requested' || sendable ? exited : false + ) + return Promise.race([exited.then((proven) => proven || stopped), stopped]) + } + + /** The bridge saw the pane exit. */ + exited(ptyId: string): void { + this.exits.observe(ptyId) + } + /** Runs after each listing: re-sends pending stops, re-listing on backoff while one still runs. */ flush(bridge: StopTarget): void { this.bridge = bridge - const panes = new Map(bridge.snapshot().map((pane) => [pane.ptyId, pane])) - const leased = leasedAppPtyIds(this.targetId, this.leases()) + const leases = this.leases() + const listed = listedAppPtyIds(this.targetId, leases) + const owed = new Set([...this.closing.keys(), ...this.persisted(leases).keys()]) let pending = false - for (const ptyId of this.closing.keys()) { - const pane = panes.get(ptyId) - if (!pane) { - if (!leased.has(ptyId)) { + for (const ptyId of owed) { + const state = bridge.paneState(ptyId) + if (!state) { + if (!listed.has(ptyId)) { this.closing.delete(ptyId) } continue } pending = true - if (pane.state === 'live') { + if (state === 'live') { void this.send(ptyId, bridge) } } @@ -85,17 +139,77 @@ export class LegacyRelayPaneStops { dispose(): void { this.relist.cancel() + this.exits.dispose() this.closing.clear() } + /** Whether its tab closed and the stop is still owed, recorded now or by a previous run. */ + owes(ptyId: string): boolean { + return this.pending(ptyId) !== undefined + } + get size(): number { return this.closing.size } + /** This session's record of the stop, else the one a previous run persisted. */ + private pending(ptyId: string): PendingStop | undefined { + return this.closing.get(ptyId) ?? this.persisted(this.leases()).get(ptyId) + } + + private persisted(leases: SshRemotePtyLease[]): Map { + const stops = new Map() + for (const lease of leases) { + if (isStoppingLease(lease)) { + const incarnationId = lease.pendingKill?.incarnationId + stops.set(toAppSshPtyId(this.targetId, lease.ptyId), incarnationId ? { incarnationId } : {}) + } + } + return stops + } + + /** Persists a stop a later run can fence (a `pty2:` id or a listed incarnation); false if none. */ + private record(ptyId: string, incarnationId: string | undefined): boolean { + const relayPtyId = parseAppSshPtyId(ptyId)?.relayPtyId + if (!relayPtyId || (!incarnationId && !relayPtyId.startsWith('pty2:'))) { + return false + } + const earlier = this.leases().find((lease) => lease.ptyId === relayPtyId)?.pendingKill + // A repeated close keeps the first one's age, which a downgraded build's expiry reads. + if (!earlier || earlier.incarnationId !== incarnationId) { + this.persist(relayPtyId, { + requestedAt: Date.now(), + ...(incarnationId ? { incarnationId } : {}), + attempts: 0 + }) + } + return true + } + + /** + * The stop can't be proven to reach the tab's process, so it is never sent. The lease ends as any + * closed tab's does, so a process that took the id is never served under the closed tab. + */ + private abandon(ptyId: string, bridge: StopTarget | null, attempted = false): void { + this.closing.delete(ptyId) + const relayPtyId = parseAppSshPtyId(ptyId)?.relayPtyId + if (relayPtyId) { + this.host()?.retireLease( + this.targetId, + relayPtyId, + PtyRetirementWithoutProof.because('the old relay could not be sent a stop fenced to it') + ) + } + // The next listing drops the pane it still holds for that lease; after an attempted stop, only + // once the relay's exit report had its chance, since dropping the pane first would hide it. + const relist = attempted ? this.exits.wait(ptyId, UNFENCED_EXIT_WAIT_MS) : Promise.resolve() + void relist.then(() => bridge?.refresh()) + } + /** The old relay's own stop, fenced to the process the tab ran; false when it can't be asked. */ private async send(ptyId: string, bridge: StopTarget): Promise { const route = bridge.liveRoute(ptyId) - const pending = this.closing.get(ptyId) + const pending = this.pending(ptyId) if (!route || !pending) { return false } @@ -107,7 +221,7 @@ export class LegacyRelayPaneStops { ? listed === pending.incarnationId || (listed === undefined && sameProcess) : sameProcess if (!proven) { - this.closing.delete(ptyId) + this.abandon(ptyId, bridge) return false } try { @@ -116,7 +230,7 @@ export class LegacyRelayPaneStops { } catch (error) { // The relay's own fence refused it: that id now runs another process. if (/incarnation mismatch/i.test(error instanceof Error ? error.message : String(error))) { - this.closing.delete(ptyId) + this.abandon(ptyId, bridge) } return false } diff --git a/src/main/ssh/legacy-relay/legacy-relay-target-bridge.test.ts b/src/main/ssh/legacy-relay/legacy-relay-target-bridge.test.ts index 26b2eceea6d4..763f749274ef 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-target-bridge.test.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-target-bridge.test.ts @@ -123,15 +123,33 @@ let endpointExited: boolean let stops: LegacyRelayPaneStops let events: { data: string[]; exits: string[]; exitedTelemetry: number; panesChanged: number } +/** Persists stops onto the leases, as the store does, so a fresh instance models a restart. */ +function paneStops(): LegacyRelayPaneStops { + return new LegacyRelayPaneStops(TARGET, () => ({ + leases: () => leases.filter((row) => row.state !== 'terminated'), + recordStop: (_target, relayPtyId, pendingKill) => { + leases = leases.map((row) => (row.ptyId === relayPtyId ? { ...row, pendingKill } : row)) + }, + retireLease: (_target, relayPtyId) => retire(relayPtyId) + })) +} + +/** As the store's retire records: terminated, and any stop it carried delivered. */ +function retire(relayPtyId: string): void { + leases = leases.map((row) => { + if (row.ptyId !== relayPtyId) { + return row + } + const { pendingKill: _delivered, ...rest } = row + return { ...rest, state: 'terminated' } + }) +} + function bridge(overrides: Partial = {}): LegacyRelayTargetBridge { const subject: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({ targetId: TARGET, leases: () => leases.filter((row) => row.state !== 'terminated'), - retireLease: (relayPtyId) => { - leases = leases.map((row) => - row.ptyId === relayPtyId ? { ...row, state: 'terminated' } : row - ) - }, + retireLease: retire, listEndpoints: async () => listing, openClient: async () => { if (relay.openError) { @@ -147,7 +165,11 @@ function bridge(overrides: Partial = {}): LegacyRel stops.flush(subject) }, onData: (_id, chunk) => events.data.push(chunk), - onExit: (id) => events.exits.push(id), + onExit: (id) => { + events.exits.push(id) + // As the registry wires it. + stops.exited(id) + }, onTerminalExited: () => { events.exitedTelemetry += 1 }, @@ -168,7 +190,7 @@ beforeEach(() => { incarnations: new Map(), exitOnShutdown: true } - stops = new LegacyRelayPaneStops(TARGET, () => leases.filter((row) => row.state !== 'terminated')) + stops = paneStops() leases = [lease()] listing = { kind: 'listed', endpoints: [ENDPOINT], exhaustive: true } endpointExited = false @@ -331,6 +353,16 @@ describe('one targetโ€™s legacy relay bridge', () => { const subject = bridge() await subject.refresh() expect(subject.snapshot()).toEqual([]) + expect(stops.owes(PTY)).toBe(false) + // Listed only for its stop, which an unfenced legacy id never proves, so it is dropped. + expect(relay.shutdowns).toEqual([]) + expect(leases[0].pendingKill).toBeUndefined() + }) + + it('never lists a relay for a lease a newer pane won', async () => { + leases = [lease({ supersededBy: 'pty-3' }), lease({ ptyId: 'pty-4', tabId: undefined })] + relay.ptys = new Set(['pty-1', 'pty-4']) + await bridge().refresh() expect(relay.clients).toEqual([]) }) @@ -347,6 +379,7 @@ describe('one targetโ€™s legacy relay bridge', () => { it('retires a stopped shell once a listing proves it gone, without an exit report', async () => { relay.exitOnShutdown = false + relay.incarnations.set('pty-1', 'inc-1') const subject = bridge() await subject.refresh() await stops.stop(PTY, subject) @@ -400,6 +433,126 @@ describe('one targetโ€™s legacy relay bridge', () => { await vi.advanceTimersByTimeAsync(10_000) expect(relay.shutdowns).toEqual([]) expect(stops.size).toBe(0) + // The closed tab's lease ends, so the stranger is never served under it. + expect(leases[0].state).toBe('terminated') + expect(subject.snapshot()).toEqual([]) + }) + + it('never re-aims a repeated close at a process that took the closed tabโ€™s relay id', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + // A restarted relay reuses the id for another shell, and the close repeats before any flush. + const client = fakeClient(relay) + const recycled = { + paneState: () => 'live' as const, + liveRoute: () => ({ client, relayPtyId: 'pty-1' }), + listedIncarnation: () => 'inc-2', + refresh: async () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop() reads only these members. + await stops.stop(PTY, recycled as unknown as LegacyRelayTargetBridge) + expect(relay.shutdowns).toEqual([]) + expect(leases[0].state).toBe('terminated') + }) + + it('proves a stop only by the relayโ€™s exit report, never by the stopโ€™s reply', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + const subject = bridge() + await subject.refresh() + await expect(stops.stopAndWait(epochPty, subject, Date.now() + 5_000)).resolves.toBe(true) + }) + + it('settles on the relayโ€™s exit report even when the stopโ€™s own reply is lost', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + const client = fakeClient(relay) + client.shutdown = async () => { + stops.exited(epochPty) + throw new Error('request timed out') + } + const lostReply = { + paneState: () => 'live' as const, + liveRoute: () => ({ client, relayPtyId: 'pty2:epoch:1' }), + listedIncarnation: () => undefined, + refresh: async () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop() reads only these members. + const target = lostReply as unknown as LegacyRelayTargetBridge + await expect(stops.stopAndWait(epochPty, target, Date.now() + 5_000)).resolves.toBe(true) + }) + + it('still settles on an exit report that trails a failed stop reply', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + relay.shutdownError = new Error('request timed out') + const subject = bridge() + await subject.refresh() + const settled = stops.stopAndWait(epochPty, subject, Date.now() + 5_000) + await vi.advanceTimersByTimeAsync(100) + relay.clients[0].emitExit('pty2:epoch:1', 0) + await expect(settled).resolves.toBe(true) + }) + + it('still sees an unfenced stopโ€™s exit report that trails its reply, never re-serving the pane', async () => { + relay.exitOnShutdown = false + const subject = bridge() + await subject.refresh() + const settled = stops.stopAndWait(PTY, subject, Date.now() + 5_000) + await vi.advanceTimersByTimeAsync(0) + expect(relay.shutdowns).toEqual(['pty-1']) + expect(subject.snapshot()).toEqual([]) + relay.clients[0].emitExit('pty-1', 0) + await expect(settled).resolves.toBe(true) + }) + + it('still sees an unfenced stopโ€™s exit report after its reply timed out', async () => { + relay.shutdownError = new Error('request timed out') + const subject = bridge() + await subject.refresh() + const settled = stops.stopAndWait(PTY, subject, Date.now() + 5_000) + await vi.advanceTimersByTimeAsync(100) + expect(subject.snapshot()).toEqual([]) + relay.clients[0].emitExit('pty-1', 0) + await expect(settled).resolves.toBe(true) + }) + + it('leaves a taken stop unproven while no exit report or complete listing confirms it', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + relay.exitOnShutdown = false + // A relay may run that no endpoint accounts for, so absence from the listing is not exit. + listing = { kind: 'listed', endpoints: [ENDPOINT], exhaustive: false } + const subject = bridge() + await subject.refresh() + const settled = stops.stopAndWait(epochPty, subject, Date.now() + 1_000) + await vi.advanceTimersByTimeAsync(1_000) + await expect(settled).resolves.toBe(false) + expect(relay.shutdowns).toEqual(['pty2:epoch:1']) + }) + + it('never attaches a pane whose tab closed while the attach waited for its relay', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + relay.openError = new Error('ssh down') + const subject = bridge() + await subject.refresh() + const attach = subject.attach(epochPty) + // The tab closes while the attach waits for the unreachable relay. + await stops.stop(epochPty, null) + relay.openError = null + relay.shutdownError = new Error('timed out') + await subject.refresh() + await expect(attach).resolves.toEqual({ status: 'not-bridged' }) + expect(relay.clients.every((client) => client.writes.length === 0)).toBe(true) }) it('sends a close recorded while disconnected once the next bridge lists the pane', async () => { @@ -412,11 +565,91 @@ describe('one targetโ€™s legacy relay bridge', () => { expect(relay.shutdowns).toEqual(['pty2:epoch:1']) }) + it('sends a close recorded while disconnected after a restart before the reconnect', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + await stops.stop(epochPty, null) + // Restart: only the persisted leases survive. + stops = paneStops() + const subject = bridge() + await subject.refresh() + expect(relay.shutdowns).toEqual(['pty2:epoch:1']) + // A closed tab's shell is stopped, never offered back as a pane to reattach. + expect(subject.snapshot()).toEqual([]) + expect(leases[0]).toMatchObject({ state: 'terminated' }) + expect(leases[0].pendingKill).toBeUndefined() + }) + + it('keeps the first closeโ€™s record when the tabโ€™s close repeats', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + await stops.stop(epochPty, null) + const first = leases[0].pendingKill + await vi.advanceTimersByTimeAsync(5_000) + await stops.stop(epochPty, null) + expect(leases[0].pendingKill).toBe(first) + }) + + it('restores a fenced stop across a restart and sends it only to the same process', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + subject.dispose() + stops = paneStops() + relay.openError = null + await bridge().refresh() + expect(relay.shutdowns).toEqual(['pty-1#inc-1']) + }) + + it('drops a restored stop whose relay id now names another process, never serving it', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + subject.dispose() + stops = paneStops() + relay.incarnations.set('pty-1', 'inc-2') + relay.openError = null + const next = bridge() + await next.refresh() + await vi.advanceTimersByTimeAsync(10_000) + expect(relay.shutdowns).toEqual([]) + expect(leases[0]).toMatchObject({ state: 'terminated' }) + expect(leases[0].pendingKill).toBeUndefined() + expect(next.snapshot()).toEqual([]) + }) + + it('stops serving an unfenced legacy pane once its one stop failed', async () => { + const subject = bridge() + await subject.refresh() + relay.shutdownError = new Error('timed out') + await stops.stop(PTY, subject) + await vi.advanceTimersByTimeAsync(0) + expect(subject.snapshot()).toEqual([]) + expect(subject.livePaneCount()).toBe(0) + }) + + it('never serves an unfenced legacy pane again after its tab closed and Orca restarted', async () => { + await expect(stops.stop(PTY, null)).resolves.toEqual({ status: 'unverifiable' }) + stops = paneStops() + const subject = bridge() + await subject.refresh() + expect(subject.snapshot()).toEqual([]) + expect(relay.shutdowns).toEqual([]) + }) + it('never replays a blind close for a legacy id that could name another shell', async () => { await stops.stop(PTY, null) await bridge().refresh() expect(relay.shutdowns).toEqual([]) - expect(leases[0].state).toBe('detached') + // The tab closed, so its lease ends as any closed tab's does. + expect(leases[0].state).toBe('terminated') }) it('never replays a fenced legacy stop to a relay that no longer reports the incarnation', async () => { @@ -441,8 +674,10 @@ describe('one targetโ€™s legacy relay bridge', () => { relay.shutdownError = null relay.clients[0].drop() await vi.advanceTimersByTimeAsync(10_000) - expect(relay.clients.length).toBeGreaterThan(1) expect(relay.shutdowns).toEqual([]) + // One try only: the lease ends as any closed tab's does, so no later relay is ever asked. + expect(leases[0].state).toBe('terminated') + expect(subject.snapshot()).toEqual([]) }) it('never republishes a pane that exited while a refresh was asking its relay', async () => { diff --git a/src/main/ssh/legacy-relay/legacy-relay-target-bridge.ts b/src/main/ssh/legacy-relay/legacy-relay-target-bridge.ts index 0835e9b82d6a..49e7f6df2d3c 100644 --- a/src/main/ssh/legacy-relay/legacy-relay-target-bridge.ts +++ b/src/main/ssh/legacy-relay/legacy-relay-target-bridge.ts @@ -18,12 +18,13 @@ import type { LegacyRelayEndpoint, LegacyRelayEndpointListing } from './legacy-r import { LegacyRelayRetry, LegacyRelayWaiters } from './legacy-relay-bridge-timers' import { ATTACH_WAIT_MS, - bridgeableLeasesByRelayId, findLivePane, leasedAppPtyIds, + listedLeasesByRelayId, type LegacyRelayPty, PROVEN_EXITED, - toBridgedPane, + servedBridgedPanes, + toPane, type Pane } from './legacy-relay-bridge-leases' @@ -31,7 +32,8 @@ export type LegacyRelayTargetBridgeDeps = { targetId: string /** The target's non-terminated leases. */ leases: () => SshRemotePtyLease[] - retireLease: (relayPtyId: string) => void + /** Only on the relay's word: its exit report, or every relay answered and none holds it. */ + retireLease: (relayPtyId: string, proof: 'exit-observed' | 'absent') => void listEndpoints: (signal: AbortSignal) => Promise openClient: ( endpoint: LegacyRelayEndpoint, @@ -57,9 +59,12 @@ type EndpointState = { export class LegacyRelayTargetBridge { private readonly endpoints = new Map() private readonly panes = new Map() - readonly livePaneForKey = (paneKey: string) => findLivePane(this.panes, paneKey) + readonly livePaneForKey = (paneKey: string) => + findLivePane(this.panes, paneKey, this.deps.leases()) /** The process identity the relay listed for a pane, which fences a stop against a recycled id. */ readonly listedIncarnation = (ptyId: string) => this.panes.get(ptyId)?.listed?.incarnationId + /** Whether a relay lists a pane now, including one whose stop is owed; undefined when untracked. */ + readonly paneState = (ptyId: string) => this.panes.get(ptyId)?.state private readonly attached = new Map>() private readonly waiters = new LegacyRelayWaiters() private readonly retry = new LegacyRelayRetry(() => void this.refresh()) @@ -73,16 +78,12 @@ export class LegacyRelayTargetBridge { constructor(private readonly deps: LegacyRelayTargetBridgeDeps) {} - get targetId(): string { - return this.deps.targetId - } - livePaneCount(): number { - return [...this.panes.values()].filter((pane) => pane.state === 'live').length + return this.snapshot().filter((pane) => pane.state === 'live').length } snapshot(): LegacyRelayBridgedPane[] { - return [...this.panes].map(([ptyId, pane]) => toBridgedPane(this.deps.targetId, ptyId, pane)) + return servedBridgedPanes(this.deps.targetId, this.panes, this.deps.leases()) } /** Re-reads every relay; concurrent callers share one pass. */ @@ -106,10 +107,14 @@ export class LegacyRelayTargetBridge { ) pane = this.panes.get(ptyId) } + const leased = leasedAppPtyIds(this.deps.targetId, this.deps.leases()).has(ptyId) if (!pane) { - const leased = leasedAppPtyIds(this.deps.targetId, this.deps.leases()).has(ptyId) return leased ? { status: 'unverifiable' } : { status: 'exited' } } + // A close recorded while this attach waited: its stop is owed, so the pane is never served. + if (!leased) { + return { status: 'not-bridged' } + } const client = pane.endpoint ? this.endpoints.get(pane.endpoint)?.client : null if (pane.state !== 'live' || !client) { return { status: 'unverifiable' } @@ -173,7 +178,7 @@ export class LegacyRelayTargetBridge { return } this.looked = true - const leases = bridgeableLeasesByRelayId(this.deps.leases()) + const leases = listedLeasesByRelayId(this.deps.leases()) if (leases.size === 0) { this.closeIdleClients(new Set()) this.replacePanes(new Map()) @@ -185,7 +190,7 @@ export class LegacyRelayTargetBridge { for (const [relayPtyId, lease] of leases) { const ptyId = toAppSshPtyId(this.deps.targetId, relayPtyId) if (!seeded.has(ptyId)) { - seeded.set(ptyId, { lease, relayPtyId, endpoint: null, state: 'unverifiable' }) + seeded.set(ptyId, toPane(lease, relayPtyId, null, 'unverifiable')) } } if (seeded.size !== this.panes.size) { @@ -212,22 +217,20 @@ export class LegacyRelayTargetBridge { } const next = new Map() // Why re-read: a lease retired while the relays were asked (an exit) must not come back live. - const current = [...bridgeableLeasesByRelayId(this.deps.leases())].filter(([id]) => - leases.has(id) - ) + const current = [...listedLeasesByRelayId(this.deps.leases())].filter(([id]) => leases.has(id)) for (const [relayPtyId, lease] of current) { const ptyId = toAppSshPtyId(this.deps.targetId, relayPtyId) const listed = listedBy.get(relayPtyId) if (listed) { - next.set(ptyId, { lease, relayPtyId, endpoint: listed.endpoint, state: 'live', listed }) + next.set(ptyId, toPane(lease, relayPtyId, listed.endpoint, 'live', listed)) } else if (complete) { // Every relay this target could have left answered, and none holds it. const wasLive = this.panes.get(ptyId)?.endpoint - this.deps.retireLease(relayPtyId) + this.deps.retireLease(relayPtyId, 'absent') this.emitExit(ptyId, -1, Boolean(wasLive)) } else { // Unreachable: loss of contact is never exit, so the pane waits. - next.set(ptyId, { lease, relayPtyId, endpoint: null, state: 'unverifiable' }) + next.set(ptyId, toPane(lease, relayPtyId, null, 'unverifiable')) } } this.closeIdleClients(new Set([...next.values()].map((pane) => pane.endpoint ?? ''))) @@ -304,7 +307,8 @@ export class LegacyRelayTargetBridge { } private retire(ptyId: string, pane: Pane, code: number | null): void { - this.deps.retireLease(pane.relayPtyId) + // A null code is the relay refusing an attach as proven exited. + this.deps.retireLease(pane.relayPtyId, code === null ? 'absent' : 'exit-observed') this.panes.delete(ptyId) this.emitExit(ptyId, code ?? -1) this.closeIdleClients(new Set([...this.panes.values()].map((entry) => entry.endpoint ?? ''))) diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts index ea1ad29cd53f..4801d0280466 100644 --- a/src/main/ssh/orcad-installed-activation.ts +++ b/src/main/ssh/orcad-installed-activation.ts @@ -23,6 +23,12 @@ import { planOrcadUpdate } from './orcad-update-plan' import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' import { ORCAD_LOG_FILENAME } from '../managed-server/install/orcad-remote-host-support' +import { orcadSnapshotFailureReason } from '../managed-server/host-state/orcad-state-snapshot-space' +import { + ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE, + orcadSnapshotRoomRefusal, + orcadSnapshotsRoot +} from './orcad-snapshot-room' import { captureOrcadStateSnapshotCommand, orcadSnapshotDirName, @@ -167,6 +173,12 @@ export async function activateInstalledOrcad( } } + // Before the recount: a slow scan must not outdate the census that admits the stop. + const roomless = await orcadSnapshotRoomRefusal(options, record, fullVersion) + if (roomless) { + return notActivated(ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE, roomless) + } + // Last check before anything stops: the census above may predate a long upload. if (incumbent && options.recountCensus && countedLockedRecord(options, record)) { const recount = planOrcadUpdate({ @@ -209,26 +221,32 @@ export async function activateInstalledOrcad( // A live SQLite WAL is not a backup boundary, so the snapshot waits for confirmed exit. const snapshotDir = orcadSnapshotPath(options, transaction.snapshot.dirName) - const capture = parseOrcadSnapshotCapture( - await execOrcadStateMutationOr( - options, - captureOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - snapshotDir, - orcadRemoteBaseDir(options.host, options.remoteHome) - ), - 'FAILED' - ) + const captureOutput = await execOrcadStateMutationOr( + options, + captureOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + snapshotDir, + orcadRemoteBaseDir(options.host, options.remoteHome), + { + snapshotsRoot: orcadSnapshotsRoot(options), + keep: [transaction.snapshot.dirName, ...(record.snapshot ? [record.snapshot.dirName] : [])] + } + ), + 'FAILED' ) + const capture = parseOrcadSnapshotCapture(captureOutput) if (capture === 'failed') { + const reason = orcadSnapshotFailureReason(captureOutput) const restarted = incumbent ? ` The incumbent was stopped before snapshotting; ${await restartAfterSnapshotFailure(options, incumbent, lock)}` : '' throw new Error( - `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + - 'persisted state carries no schema version, so without a snapshot a rollback has no ' + - `way back. Refusing to activate.${restarted}` + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}` + + `${reason ? `: ${reason}` : ''}. Orca's persisted state carries no schema version, so ` + + 'without a snapshot a rollback has no way back. Refusing to activate. Fix what stops ' + + `the copy (often a full disk or quota on ${orcadRemoteBaseDir(options.host, options.remoteHome)}), ` + + `then reconnect.${restarted}` ) } const snapshot: OrcadStateSnapshot | null = diff --git a/src/main/ssh/orcad-migration-catalog-client.test.ts b/src/main/ssh/orcad-migration-catalog-client.test.ts index 9b9783216b9b..7e86eb825f10 100644 --- a/src/main/ssh/orcad-migration-catalog-client.test.ts +++ b/src/main/ssh/orcad-migration-catalog-client.test.ts @@ -115,7 +115,12 @@ describe('remote orcad migration catalog client', () => { { id: 'nested', folderPath: '/root/b', projectGroupId: 'g', connectionId: 'ssh-x' } ] } - : { groups: [{ id: 'g', name: 'G', parentGroupId: null }, { id: 'h' }] } + : { + groups: [ + { id: 'g', name: 'G', parentGroupId: null }, + { id: 'h', executionHostId: 'ssh:other' } + ] + } })) await expect(listRemoteOrcadFolderCatalog(pairingCode)).resolves.toEqual({ folderWorkspaces: [ @@ -124,7 +129,7 @@ describe('remote orcad migration catalog client', () => { ], projectGroups: [ { id: 'g', parentGroupId: null }, - { id: 'h', parentGroupId: null } + { id: 'h', parentGroupId: null, executionHostId: 'ssh:other' } ] }) expect(sendRequest.mock.calls.map((call) => call[1]).sort()).toEqual([ diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index cca170c90fdc..38ebb2400be0 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -142,7 +142,7 @@ describe('migration cutover coordinator', () => { it.each([ [ 'a source row changed', - (store: Store) => store.updateRepo('repo-1', { displayName: 'Renamed' }), + (store: Store) => store.updateRepo(store.getRepo('repo-1')!, { displayName: 'Renamed' }), 'orcad_migration_source_changed' ], [ @@ -165,7 +165,7 @@ describe('migration cutover coordinator', () => { it('refuses a commit whose source changed after staging', async () => { const h = await setup() await stageOrcadMigrationDestination(h.context, h.migrationId) - h.store.updateRepo('repo-1', { displayName: 'Renamed' }) + h.store.updateRepo(h.store.getRepo('repo-1')!, { displayName: 'Renamed' }) await expect(commitOrcadMigrationDestination(h.context, h.migrationId)).rejects.toThrow( 'orcad_migration_source_changed' ) diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index ef7c71f0dadb..346b9b0b4d34 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -18,10 +18,18 @@ import { } from '../persistence-test-harness' import type { PersistedState } from '../../shared/persisted-state-types' import { Store } from '../persistence/loading-store/store' -import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' +import { + listOrcadMigrationCutoverChainForTarget, + listOrcadMigrationSourceCutovers, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' import { fakeOrcadMigrationDestination } from './orcad-migration-destination-fake' import { keepOrcadServerVersion, runOrcadDeltaMove } from './orcad-migration-delta-move' -import { planOrcadDeltaMove } from './orcad-migration-delta-plan' +import { orcadSourceStoreWithout, planOrcadDeltaMove } from './orcad-migration-delta-plan' +import { + createOrcadMigrationManifest, + orcadMigrationCatalogIds +} from './orcad-migration-manifest-export' import { latestOrcadMigrationInto } from './orcad-migration-rollback-mark' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' import { reconcileManagedOrcadSshTargets, visibleRepos } from './orcad-retained-source' @@ -128,7 +136,7 @@ async function convertedThenChangedOnOlderBuild(): Promise { }) ).resolves.toMatchObject({ outcome: 'converted' }) store.addRepo(repo('repo-2', '/srv/tool')) - store.updateRepo('repo-1', { displayName: 'app-renamed' }) + store.updateRepo(store.getRepo('repo-1')!, { displayName: 'app-renamed' }) reconcileManagedOrcadSshTargets(userDataPath, store, now) expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeDefined() } @@ -410,6 +418,44 @@ describe('moving what an older build added to a converted host', () => { expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeUndefined() }) + // Codex repro: an older build's delta exported the moved group its new row sits in. + it('resumes an older buildโ€™s interrupted delta that exported a moved group', async () => { + const group = store.createProjectGroup({ + name: 'Apps', + parentPath: '/srv', + connectionId: TARGET.id, + createdFrom: 'manual' + }) + store.moveProjectToGroup('repo-1', group.id) + await convertedThenChangedOnOlderBuild() + store.moveProjectToGroup('repo-2', group.id) + const reconnect = loseContactAtDeltaCommit() + await expect(deltaMove()).rejects.toThrow('socket closed') + const target = store.getSshTarget(TARGET.id)! + const [first, delta] = listOrcadMigrationCutoverChainForTarget(userDataPath, TARGET.id) + // The delta source an older build cut, which still exported every group a new row sits in. + const olderSource = orcadSourceStoreWithout( + store, + target, + orcadMigrationCatalogIds([first!.manifest.payload]), + first!.destinationEnvironmentId + ) + const manifest = createOrcadMigrationManifest(olderSource, target, { + migrationId: delta!.migrationId, + destinationEnvironmentId: delta!.destinationEnvironmentId, + now: () => new Date(delta!.manifest.createdAt) + }) + expect(manifest.payload.projectGroups.map((entry) => entry.id)).toEqual([group.id]) + writeOrcadMigrationSourceCutover(userDataPath, { + ...delta!, + manifest, + manifestSha256: manifest.manifestSha256 + }) + + reconnect() + await expect(deltaMove()).resolves.toMatchObject({ outcome: 'moved' }) + }) + it('backs out an interrupted delta the source has since outgrown, then moves afresh', async () => { await convertedThenChangedOnOlderBuild() const reconnect = loseContactAtDeltaCommit() diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index be3a8f3d71b3..b3a10bd537d9 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -150,7 +150,7 @@ async function commitDelta( const context: OrcadMigrationCutoverContext = { userDataPath, store: plan.source, - freshSource: () => orcadDeltaSourceStore(store, target, plan.moved), + freshSource: () => orcadDeltaSourceStore(store, target, plan.moved, cutover.manifest), claims: args.claims, destination: args.destination, now diff --git a/src/main/ssh/orcad-migration-delta-plan.ts b/src/main/ssh/orcad-migration-delta-plan.ts index 567543633453..f67dfbf1f242 100644 --- a/src/main/ssh/orcad-migration-delta-plan.ts +++ b/src/main/ssh/orcad-migration-delta-plan.ts @@ -53,15 +53,19 @@ export function committedOrcadMigrationChain( export function orcadDeltaSourceStore( store: Store, target: SshTarget, - moved: readonly OrcadMigrationSourceCutover[] + moved: readonly OrcadMigrationSourceCutover[], + /** A journaled delta's manifest: an older build may have exported server groups in it. */ + journaled?: OrcadMigrationManifest ): OrcadDeltaSourceStore { const ids = orcadMigrationCatalogIds(moved.map((cutover) => cutover.manifest.payload)) // A linked project or group is the server's already, so a delta never moves it either. const linked = moved.flatMap((cutover) => cutover.linkedRepositoryIds ?? []) const linkedGroups = moved.flatMap((cutover) => cutover.linkedProjectGroupIds ?? []) const linkedFolders = moved.flatMap((cutover) => cutover.linkedFolderWorkspaceIds ?? []) - // Why linked groups subtract like moved ones: a group a new row needs still moves, since the - // server may have deleted it; an unused one never reappears as this desktop's addition. + // The server's record of a group it got from a move or link wins: a new row in it references the + // server's group, since this desktop's retained copy may be stale and would conflict. + const serverGroups = new Set([...ids.projectGroupIds, ...linkedGroups]) + const exported = new Set(journaled?.payload.projectGroups.map((group) => group.id)) return orcadSourceStoreWithout( store, target, @@ -69,9 +73,10 @@ export function orcadDeltaSourceStore( ...ids, repositoryIds: new Set([...ids.repositoryIds, ...linked]), folderWorkspaceIds: new Set([...ids.folderWorkspaceIds, ...linkedFolders]), - projectGroupIds: new Set([...ids.projectGroupIds, ...linkedGroups]) + projectGroupIds: serverGroups }, - moved.at(-1)?.destinationEnvironmentId + moved.at(-1)?.destinationEnvironmentId, + new Set([...serverGroups].filter((id) => !exported.has(id))) ) } @@ -131,7 +136,7 @@ export function planOrcadDeltaMove( ) { throw new Error('orcad_delta_no_retained_migration') } - const source = orcadDeltaSourceStore(store, target, moved) + const source = orcadDeltaSourceStore(store, target, moved, resumes?.manifest) const manifest = resumes?.manifest ?? createOrcadMigrationManifest(source, target, { diff --git a/src/main/ssh/orcad-migration-repository-link.test.ts b/src/main/ssh/orcad-migration-repository-link.test.ts index 885a520c01c5..66e9b60f8349 100644 --- a/src/main/ssh/orcad-migration-repository-link.test.ts +++ b/src/main/ssh/orcad-migration-repository-link.test.ts @@ -345,7 +345,7 @@ describe('moving onto a server another desktop already added the same project to }) await expect(convert()).rejects.toThrow('socket closed') otherDesktopAddsSameCheckout() - store.updateRepo('desktop-only', { displayName: 'Renamed' }) + store.updateRepo(store.getRepo('desktop-only')!, { displayName: 'Renamed' }) await expect(convert()).rejects.toThrow('orcad_migration_source_changed') expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.linkedRepositoryIds).toBeUndefined() }) @@ -387,6 +387,14 @@ describe('moving the host again after its server was stopped', () => { ) }) + const targetGroup = (name: string, parentGroupId?: string) => + store.createProjectGroup({ + name, + parentPath: '/root', + ...(parentGroupId ? { parentGroupId } : {}), + connectionId: TARGET.id, + createdFrom: 'manual' + }) function groupDesktopRepo(): string { const group = store.createProjectGroup({ name: 'Before', @@ -509,6 +517,127 @@ describe('moving the host again after its server was stopped', () => { expect(server.getRepo('added-later')?.projectGroupId).toBe(groupId) }) + // Review repro: a folder the server edited while managed must link on reconnect. + it('links a folder workspace the server edited while managed', async () => { + const group = targetGroup('Notes') + const folder = store.createFolderWorkspace({ + projectGroupId: group.id, + folderPath: '/root/notes' + }) + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + server.updateFolderWorkspace(folder.id, { comment: 'edited on the server' }) + stopServer() + + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + expect(server.getFolderWorkspace(folder.id)?.comment).toBe('edited on the server') + }) + + // Review repro: an empty group no server row sits in was re-imported over the server's. + it('keeps the serverโ€™s empty project group when it was renamed while managed', async () => { + const group = targetGroup('Empty') + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + server.updateProjectGroup(group.id, { name: 'Renamed on the server' }) + stopServer() + + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + expect(groupName(server, group.id)).toBe('Renamed on the server') + expect(groupName(store, group.id)).toBe('Empty') + store.addRepo(repo('added-later', '/root/later')) + reconcileManagedOrcadSshTargets(userDataPath, store) + const plan = planOrcadDeltaMove(userDataPath, store, store.getSshTarget(TARGET.id)!) + expect(plan.added.map((row) => row.id)).toEqual(['added-later']) + }) + + it('keeps the serverโ€™s empty nested groups when their parent was renamed while managed', async () => { + const parent = targetGroup('Parent') + const child = targetGroup('Child', parent.id) + await convert() + server.updateProjectGroup(parent.id, { name: 'Parent renamed on the server' }) + stopServer() + + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + expect(groupName(server, parent.id)).toBe('Parent renamed on the server') + expect(groupName(server, child.id)).toBe('Child') + }) + + it('does not link a same-id group the server keeps for another host', async () => { + const group = targetGroup('Empty') + await convert() + server.updateProjectGroup(group.id, { name: 'Renamed on the server' }) + stopServer() + const remoteCopy = server.getProjectGroups().find((entry) => entry.id === group.id)! + // The server's row sits on one of its own SSH hosts, so it is not this desktop's group. + const serverCatalog = async () => ({ + folderWorkspaces: [], + projectGroups: [{ ...remoteCopy, connectionId: 'server-ssh-host' }] + }) + await expect( + convertSshTargetToManagedOrcad(userDataPath, { + sshTargetId: TARGET.id, + name: 'Managed', + censusHost: async () => ({ verdict: 'exited', count: 0 }), + destinationFor: () => destination, + serverRepositoriesFor: async () => [], + serverFolderCatalogFor: serverCatalog + }) + ).rejects.toThrow(`orcad_migration_project_group_id_conflict:${group.id}`) + }) + + // Review repro: a later folder in the relinked group re-exported this desktop's old group. + it('moves a later folder into the serverโ€™s renamed group without re-exporting it', async () => { + const groupId = groupDesktopRepo() + await convert() + server.updateProjectGroup(groupId, { name: 'Renamed on the server' }) + stopServer() + await expect(convert()).resolves.toMatchObject({ outcome: 'converted' }) + const folder = store.createFolderWorkspace({ + projectGroupId: groupId, + folderPath: '/root/notes' + }) + await expect(runDelta()).resolves.toMatchObject({ outcome: 'moved' }) + expect(server.getFolderWorkspace(folder.id)?.projectGroupId).toBe(groupId) + expect(groupName(server, groupId)).toBe('Renamed on the server') + }) + + // Opus repro: a group the first move imported, changed on the server, was re-exported too. + it('moves a later row into a group the first move imported and the server changed', async () => { + const groupId = groupDesktopRepo() + await convert() + server.updateProjectGroup(groupId, { name: 'Renamed on the server', isCollapsed: true }) + store.addRepo(repo('added-later', '/root/later')) + store.moveProjectToGroup('added-later', groupId) + await expect(runDelta()).resolves.toMatchObject({ outcome: 'moved' }) + expect(server.getRepo('added-later')?.projectGroupId).toBe(groupId) + expect(groupName(server, groupId)).toBe('Renamed on the server') + }) + + // The server's deletion stands: the move is refused whole and the host stays marked changed. + it.each([ + ['linked', true], + ['moved', false] + ])( + 'refuses, without resurrecting it, a later row in a %s group the server deleted', + async (_kind, relink) => { + const groupId = groupDesktopRepo() + await convert() + if (relink) { + stopServer() + await convert() + } + server.moveProjectToGroup('desktop-repo', null) + server.deleteProjectGroup(groupId) + store.addRepo(repo('added-later', '/root/later')) + store.moveProjectToGroup('added-later', groupId) + await expect(runDelta()).resolves.toMatchObject({ + outcome: 'refused', + code: 'orcad_delta_refused_by_server' + }) + expect(server.getRepo('added-later')).toBeUndefined() + expect(groupName(server, groupId)).toBeUndefined() + expect(store.getSshTarget(TARGET.id)?.orcadFence?.sourceChangedAt).toBeDefined() + } + ) + it('resumes, not relinks, a move the server committed before the reply was lost', async () => { const commit = destination.commit destination.commit = async (manifest) => { diff --git a/src/main/ssh/orcad-migration-repository-link.ts b/src/main/ssh/orcad-migration-repository-link.ts index 97d35d27ac10..039d01405a6d 100644 --- a/src/main/ssh/orcad-migration-repository-link.ts +++ b/src/main/ssh/orcad-migration-repository-link.ts @@ -32,6 +32,7 @@ import { folderWorkspacesHeldByServer, type OrcadServerFolderCatalog } from './orcad-server-folder-workspaces' +import { projectGroupsHeldByServer, serverHeldProjectGroups } from './orcad-server-project-groups' import type { SshTargetOrcadClaims } from './targets/ssh-target-orcad-claims' export type OrcadServerRepository = Pick & { @@ -78,58 +79,23 @@ async function serverHeldRowsToLink( destination: OrcadMigrationDestinationCatalog, server: readonly OrcadServerRepository[], serverFolders: OrcadServerFolderCatalog -): Promise<{ repositoryIds: string[]; folderWorkspaceIds: string[] }> { +): Promise<{ repositoryIds: string[]; folderWorkspaceIds: string[]; projectGroupIds: string[] }> { const { payload } = cutover.manifest const held = repositoriesHeldByServer(payload.repositories, server) const heldFolders = folderWorkspacesHeldByServer(payload.folderWorkspaces, serverFolders, server) - if (held.sameId.length === 0 && heldFolders.length === 0) { - return { repositoryIds: held.otherId, folderWorkspaceIds: [] } + const heldGroups = projectGroupsHeldByServer(payload.projectGroups, serverFolders) + const otherIdOnly = { repositoryIds: held.otherId, folderWorkspaceIds: [], projectGroupIds: [] } + if (held.sameId.length === 0 && heldFolders.length === 0 && heldGroups.length === 0) { + return otherIdOnly } const state = await destination.readState(cutover.manifest) return state.state === 'committed' - ? { repositoryIds: held.otherId, folderWorkspaceIds: [] } - : { repositoryIds: [...held.otherId, ...held.sameId], folderWorkspaceIds: heldFolders } -} - -/** - * Source groups the server's projects sit in, and their ancestors, renamed there perhaps since: - * re-importing this desktop's retained copy would conflict with the server's. An ancestor the - * server may lack still moves while a moving row needs it. - */ -function serverHeldProjectGroups( - payload: OrcadMigrationSourceCutover['manifest']['payload'], - linked: { repositoryIds: ReadonlySet; folderWorkspaceIds: ReadonlySet }, - server: readonly { projectGroupId?: string | null }[] -): string[] { - const parentById = new Map(payload.projectGroups.map((group) => [group.id, group.parentGroupId])) - const held = new Set(server.flatMap((row) => (row.projectGroupId ? [row.projectGroupId] : []))) - const withAncestors = (ids: (string | null | undefined)[], stopAt: ReadonlySet) => { - const out = new Set() - while (ids.length > 0) { - const id = ids.pop() - if (id && !out.has(id) && !stopAt.has(id) && parentById.has(id)) { - out.add(id) - ids.push(parentById.get(id)) + ? otherIdOnly + : { + repositoryIds: [...held.otherId, ...held.sameId], + folderWorkspaceIds: heldFolders, + projectGroupIds: heldGroups } - } - return out - } - const candidates = withAncestors([...held], new Set()) - const needed = withAncestors( - [ - ...payload.repositories - .filter((repo) => !linked.repositoryIds.has(repo.id)) - .map((repo) => repo.projectGroupId), - ...payload.folderWorkspaces - .filter((workspace) => !linked.folderWorkspaceIds.has(workspace.id)) - .map((workspace) => workspace.projectGroupId), - ...payload.projectGroups - .filter((group) => !candidates.has(group.id)) - .map((group) => group.parentGroupId) - ], - held - ) - return [...candidates].filter((id) => !needed.has(id)) } /** The source the cutover was cut from: the real store less any project or group it linked. */ @@ -276,17 +242,18 @@ export async function linkServerHeldRepositories(args: { return cutover } const serverRepositories = await args.serverRepositories() - const serverFolders = - cutover.manifest.payload.folderWorkspaces.length > 0 - ? ((await args.serverFolderCatalog?.()) ?? NO_SERVER_FOLDERS) - : NO_SERVER_FOLDERS - const { repositoryIds: linked, folderWorkspaceIds: folders } = await serverHeldRowsToLink( - cutover, - args.destination, - serverRepositories, - serverFolders - ) - if (linked.length === 0 && folders.length === 0) { + const { folderWorkspaces, projectGroups } = cutover.manifest.payload + const serverCatalog = + folderWorkspaces.length > 0 || projectGroups.length > 0 + ? await args.serverFolderCatalog?.() + : undefined + const serverFolders = serverCatalog ?? NO_SERVER_FOLDERS + const { + repositoryIds: linked, + folderWorkspaceIds: folders, + projectGroupIds: heldGroups + } = await serverHeldRowsToLink(cutover, args.destination, serverRepositories, serverFolders) + if (linked.length === 0 && folders.length === 0 && heldGroups.length === 0) { return cutover } assertNoRelayTerminalsIn(args.store, cutover, linked, folders) @@ -306,7 +273,8 @@ export async function linkServerHeldRepositories(args: { repositoryIds: new Set(linkedRepositoryIds), folderWorkspaceIds: new Set(linkedFolderWorkspaceIds) }, - [...serverRepositories, ...serverFolders.folderWorkspaces] + [...serverRepositories, ...serverFolders.folderWorkspaces], + serverCatalog ) ]) ] diff --git a/src/main/ssh/orcad-migration-terminal-gate.test.ts b/src/main/ssh/orcad-migration-terminal-gate.test.ts index 7d441b5721e2..584efdc1acba 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.test.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.test.ts @@ -5,6 +5,7 @@ import { confirmOrcadMigrationTerminalsUnderFence, retireProvenExitedLeases } from './orcad-migration-terminal-gate' +import { PtyExitProof } from '../pty-exit-proof' type LeaseRow = Pick @@ -126,9 +127,9 @@ describe('acting on a terminal proof', () => { provenPtyIds: ['att', 'det', 'exp', 'ter'] }) expect(markSshRemotePtyLease.mock.calls).toEqual([ - ['ssh-1', 'att', 'terminated'], - ['ssh-1', 'det', 'terminated'], - ['ssh-1', 'exp', 'terminated'] + ['ssh-1', 'att', PtyExitProof.from('host-census')], + ['ssh-1', 'det', PtyExitProof.from('host-census')], + ['ssh-1', 'exp', PtyExitProof.from('host-census')] ]) }) diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts index c2f979868d2f..ec8e969a0a5c 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -6,6 +6,7 @@ */ import { isLiveSshPtyLease, type SshRemotePtyLease } from '../../shared/ssh-types' import type { Store } from '../persistence' +import { PtyExitProof } from '../pty-exit-proof' export type OrcadMigrationTerminalVerdict = | { verdict: 'exited'; provenPtyIds: string[] } @@ -117,9 +118,10 @@ export function retireProvenExitedLeases( return } const proven = new Set(proof.provenPtyIds) + const census = PtyExitProof.from('host-census') for (const lease of store.getSshRemotePtyLeases(targetId)) { if (lease.state !== 'terminated' && proven.has(lease.ptyId)) { - store.markSshRemotePtyLease(targetId, lease.ptyId, 'terminated') + store.markSshRemotePtyLease(targetId, lease.ptyId, census) } } } diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 6cb47d97c8b0..78e03e9c3d52 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -773,11 +773,13 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [OLD_VERSION]: readyLine({ version: OLD_VERSION }) }, log: [], - snapshotResult: 'tar: write failed' + snapshotResult: 'SNAPSHOT_FAILURE tar: Cannot write: No space left on device\nFAILED' } scriptHost(script) - await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped') + await expect(deployOrcad(options())).rejects.toThrow( + /activating \S+: tar: Cannot write: No space left on device\. .*incumbent was stopped/u + ) expect(script.log).toEqual([ 'preflight', `stop:${OLD_VERSION}`, diff --git a/src/main/ssh/orcad-remote-node-runtime-report.ts b/src/main/ssh/orcad-remote-node-runtime-report.ts index 50179a750ddb..065d28dd43fa 100644 --- a/src/main/ssh/orcad-remote-node-runtime-report.ts +++ b/src/main/ssh/orcad-remote-node-runtime-report.ts @@ -4,6 +4,8 @@ import { RelayHostAnsweredError } from './remote-exec/remote-host-answered-failu 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' +/** The store's filesystem refused to execute a file Orca wrote there; the shell's words follow. */ +export const REMOTE_NODE_RUNTIME_NOEXEC = 'ORCA_NODE_RUNTIME_NOEXEC' /** 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=' @@ -22,6 +24,34 @@ export class RemoteNodeRuntimeSelfTestError extends RelayHostAnsweredError { } } +/** The host refuses to run programs from the runtime store, e.g. a home mounted `noexec`. */ +export class RemoteNodeRuntimeNoexecError extends RelayHostAnsweredError { + constructor( + readonly output: string, + storeDir = 'Orcaโ€™s runtime folder' + ) { + super(`The host does not allow running programs from ${storeDir}: ${output}`) + this.name = 'RemoteNodeRuntimeNoexecError' + } +} + +/** + * Throws only for the shell's exec refusal; any other probe failure falls through to the upload, + * whose self-test classifies it with the loader's own words. + */ +export function assertRemoteNodeRuntimeStoreExecutable(probe: string, storeDir: string): void { + const index = probe.indexOf(REMOTE_NODE_RUNTIME_NOEXEC) + if (index === -1) { + return + } + const report = parseRemoteRuntimeExitReport(probe.slice(index)) + // Why the status alone: 126 is "found but not executable", and the probe is our own 0700 /bin/sh + // script, so only an exec refusal is left; the shell's words may be localized. + if (report.exitStatus === 126) { + throw new RemoteNodeRuntimeNoexecError(report.output, storeDir) + } +} + /** The host answered, and what it answered is that something rewrote or removed our verified bytes. */ export class RemoteNodeRuntimeSecurityModifiedError extends RelayHostAnsweredError { constructor(readonly detail: string) { diff --git a/src/main/ssh/orcad-server-folder-workspaces.ts b/src/main/ssh/orcad-server-folder-workspaces.ts index cff345526f6f..45fbd84ab812 100644 --- a/src/main/ssh/orcad-server-folder-workspaces.ts +++ b/src/main/ssh/orcad-server-folder-workspaces.ts @@ -30,7 +30,7 @@ const ServerProjectGroupsSchema = z.object({ z.object({ id: z.string().min(1), parentGroupId: z.string().nullish(), - connectionId: z.string().nullish() + ...HostFields }) ) }) diff --git a/src/main/ssh/orcad-server-project-groups.test.ts b/src/main/ssh/orcad-server-project-groups.test.ts new file mode 100644 index 000000000000..bddf7a4e00dd --- /dev/null +++ b/src/main/ssh/orcad-server-project-groups.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectGroup } from '../../shared/project-group-types' +import { projectGroupsHeldByServer, serverHeldProjectGroups } from './orcad-server-project-groups' + +function group(id: string, parentGroupId: string | null = null): ProjectGroup { + return { + id, + name: id, + parentPath: '/root', + connectionId: 'ssh-shared', + parentGroupId, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +const NOTHING_LINKED = { repositoryIds: new Set(), folderWorkspaceIds: new Set() } +const payload = (groups: ProjectGroup[]) => ({ + repositories: [], + folderWorkspaces: [], + projectGroups: groups +}) +const serverGroup = (id: string, connectionId: string | null = null) => ({ + id, + parentGroupId: null, + connectionId +}) + +describe('which project groups the server already holds', () => { + it('holds a same-id group on its own machine, though no row sits in it', () => { + expect( + projectGroupsHeldByServer([group('empty')], { projectGroups: [serverGroup('empty')] }) + ).toEqual(['empty']) + }) + + it('keeps a same-id group the server keeps for another host apart', () => { + expect( + projectGroupsHeldByServer([group('empty')], { + projectGroups: [serverGroup('empty', 'server-ssh-host')] + }) + ).toEqual([]) + }) + + it('links a held group and its ancestors', () => { + const held = serverHeldProjectGroups( + payload([group('parent'), group('child', 'parent')]), + NOTHING_LINKED, + [], + { projectGroups: [serverGroup('parent'), serverGroup('child')] } + ) + expect(held.sort()).toEqual(['child', 'parent']) + }) + + // Codex repro: a foreign server row's group was taken as this machine's, hiding the conflict. + it('takes no evidence from a server row or group on another host', () => { + const held = serverHeldProjectGroups( + payload([group('mine'), group('foreign'), group('via-row')]), + NOTHING_LINKED, + [ + { projectGroupId: 'foreign', connectionId: 'server-ssh-host' }, + { projectGroupId: 'via-row', executionHostId: 'ssh:server-ssh-host' } + ], + { projectGroups: [serverGroup('mine'), serverGroup('foreign', 'server-ssh-host')] } + ) + expect(held).toEqual(['mine']) + }) + + it('keeps a same-id group stamped with another execution host apart', () => { + expect( + projectGroupsHeldByServer([group('empty')], { + projectGroups: [{ ...serverGroup('empty'), executionHostId: 'ssh:server-ssh-host' }] + }) + ).toEqual([]) + }) + + // Codex repro: the ancestor walk took a foreign same-id parent as this machine's. + it('does not link a parent the server keeps for another host', () => { + const held = serverHeldProjectGroups( + payload([group('parent'), group('child', 'parent')]), + NOTHING_LINKED, + [], + { projectGroups: [serverGroup('parent', 'server-ssh-host'), serverGroup('child')] } + ) + expect(held).toEqual(['child']) + }) + + // Opus repro: a new parent the server lacks was linked, so a later row in it was refused. + it('does not link a parent the server lacks', () => { + const held = serverHeldProjectGroups( + payload([group('new-parent'), group('child', 'new-parent')]), + NOTHING_LINKED, + [{ projectGroupId: 'child' }], + { projectGroups: [serverGroup('child')] } + ) + expect(held).toEqual(['child']) + }) + + it('without the serverโ€™s group list, links the group a local server row sits in', () => { + expect( + serverHeldProjectGroups( + payload([group('g')]), + NOTHING_LINKED, + [{ projectGroupId: 'g' }], + undefined + ) + ).toEqual(['g']) + }) +}) diff --git a/src/main/ssh/orcad-server-project-groups.ts b/src/main/ssh/orcad-server-project-groups.ts new file mode 100644 index 000000000000..c0395ce27eb2 --- /dev/null +++ b/src/main/ssh/orcad-server-project-groups.ts @@ -0,0 +1,75 @@ +/** The project groups a managed server already holds, so a relink keeps its record of them. */ +import { isRepoOnCatalogHost } from '../../shared/execution-host' +import type { OrcadMigrationCatalogPayload } from '../../shared/orcad-migration/orcad-migration-manifest' +import type { OrcadServerFolderCatalog } from './orcad-server-folder-workspaces' + +/** + * Incoming groups the server holds under the same id on its own machine: its copy from an earlier + * move, found even when no server row sits in it. One it keeps for another host isn't the same. + */ +export function projectGroupsHeldByServer( + incoming: readonly { id: string }[], + server: Pick +): string[] { + const local = new Set(server.projectGroups.filter(isRepoOnCatalogHost).map((group) => group.id)) + return incoming.filter((group) => local.has(group.id)).map((group) => group.id) +} + +type ServerRow = { + projectGroupId?: string | null + connectionId?: string | null + executionHostId?: string | null +} + +/** + * Source groups whose record the server's wins over this desktop's retained, perhaps stale, copy. + * With the server's group list that is exactly the groups it holds: any other moves, so it is never + * recorded as the server's. Without it, the groups the server's rows sit in and their ancestors, + * less an ancestor a moving row needs, since the server may lack it. + */ +export function serverHeldProjectGroups( + payload: Pick< + OrcadMigrationCatalogPayload, + 'repositories' | 'folderWorkspaces' | 'projectGroups' + >, + linked: { repositoryIds: ReadonlySet; folderWorkspaceIds: ReadonlySet }, + server: readonly ServerRow[], + serverGroups: Pick | undefined +): string[] { + if (serverGroups) { + return projectGroupsHeldByServer(payload.projectGroups, serverGroups) + } + const parentById = new Map(payload.projectGroups.map((group) => [group.id, group.parentGroupId])) + const held = new Set( + server + .filter(isRepoOnCatalogHost) + .flatMap((row) => (row.projectGroupId ? [row.projectGroupId] : [])) + ) + const withAncestors = (ids: (string | null | undefined)[], stopAt: ReadonlySet) => { + const out = new Set() + while (ids.length > 0) { + const id = ids.pop() + if (id && !out.has(id) && !stopAt.has(id) && parentById.has(id)) { + out.add(id) + ids.push(parentById.get(id)) + } + } + return out + } + const candidates = withAncestors([...held], new Set()) + const needed = withAncestors( + [ + ...payload.repositories + .filter((repo) => !linked.repositoryIds.has(repo.id)) + .map((repo) => repo.projectGroupId), + ...payload.folderWorkspaces + .filter((workspace) => !linked.folderWorkspaceIds.has(workspace.id)) + .map((workspace) => workspace.projectGroupId), + ...payload.projectGroups + .filter((group) => !candidates.has(group.id)) + .map((group) => group.parentGroupId) + ], + held + ) + return [...candidates].filter((id) => !needed.has(id)) +} diff --git a/src/main/ssh/orcad-snapshot-room.test.ts b/src/main/ssh/orcad-snapshot-room.test.ts new file mode 100644 index 000000000000..61785a08ab5f --- /dev/null +++ b/src/main/ssh/orcad-snapshot-room.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ExecCommandModule from './remote-exec/remote-exec-command' +import type * as RecordFile from '../managed-server/install/orcad-remote-record-file' +import type * as InstallLock from '../managed-server/install/remote-install-lock' +import type * as VersionedInstall from '../managed-server/install/remote-versioned-install' + +vi.mock('./remote-exec/remote-exec-command', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) +vi.mock('../../shared/shell-argument-quoting', () => ({ quotePosixShell: (s: string) => `'${s}'` })) +vi.mock('../managed-server/install/remote-install-lock', async (importOriginal) => ({ + ...(await importOriginal()), + acquireInstallLock: vi.fn() +})) +vi.mock('../managed-server/install/orcad-remote-record-file', async (importOriginal) => ({ + ...(await importOriginal()), + writeAtomicOrcadRemoteRecord: vi.fn() +})) +vi.mock('../managed-server/install/remote-versioned-install', async (importOriginal) => ({ + ...(await importOriginal()), + readLocalFullVersion: () => '0.2.0+bb01' +})) +vi.mock('../managed-server/install/orcad-remote-install', () => ({ installOrcadBundle: vi.fn() })) +vi.mock('../managed-server/install/orcad-remote-preflight', () => ({ + preflightInstalledOrcad: vi.fn() +})) +vi.mock('./orcad-local-build-hash', () => ({ + computeLocalOrcadBuildHash: () => 'abc123def4567890' +})) + +import { execCommand } from './remote-exec/remote-exec-command' +import { acquireInstallLock } from '../managed-server/install/remote-install-lock' +import { writeAtomicOrcadRemoteRecord } from '../managed-server/install/orcad-remote-record-file' +import { deployOrcad } from './orcad-remote-deploy' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE } from './orcad-snapshot-room' +import { isSnapshotCaptureCommand } from './orcad-snapshot-capture-command' +import { OrcadFenceLostError } from '../managed-server/activation/orcad-activation-fence-scope' +import { + emptyOrcadActivationRecord, + withActivatedVersion +} from '../managed-server/activation/orcad-activation-record' +import { + FakeOrcadHost, + NEW, + OLD +} from '../managed-server/activation/orcad-activation-host-test-harness' + +let host = FakeOrcadHost.deployedOld() + +/** The host's answer to the room check: KB the state needs, KB a prune frees, and `df`'s line. */ +function answerRoom(neededKb: number, reclaimKb: number, availableKb: number): void { + vi.mocked(execCommand).mockImplementation(async (_conn, command) => + command.includes('SNAPSHOT_SPACE') + ? `SNAPSHOT_SPACE ${neededKb} ${reclaimKb} /dev/sda1 100 1 ${availableKb} 1% /home\n` + : host.exec(command) + ) +} + +const deploy = (extra: Partial[0]> = {}) => + deployOrcad({ + conn: Object.create(null), + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/work', + nodePath: '/usr/bin/node', + userDataDir: '/home/work/.orca', + bindHost: '127.0.0.1', + port: 7777, + readinessTimeoutMs: 50, + sleep: async () => {}, + now: () => new Date('2026-02-02T00:00:00.000Z'), + localOrcadDir: '/local/out/orcad', + target: 'linux-x64-glibc217', + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + ...extra + }) + +beforeEach(() => { + vi.clearAllMocks() + host = FakeOrcadHost.deployedOld() + vi.mocked(acquireInstallLock).mockImplementation(async (_conn, dir, _host, options) => { + if (!host.acquireFence(options)) { + const { RemoteInstallLockBusyError } = await vi.importActual( + '../managed-server/install/remote-install-lock' + ) + throw new RemoteInstallLockBusyError(dir, 0) + } + }) + vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => + host.write(path, contents) + ) +}) + +describe('an update on a host without room for its snapshot (#30491)', () => { + it('keeps the running server and says how much to free, stopping nothing', async () => { + answerRoom(400_000, 0, 1_000) + + const result = await deploy() + + expect(result).toMatchObject({ + outcome: 'installed-not-activated', + code: ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE + }) + expect(JSON.stringify(result)).toMatch( + /has 1000 KB free, and the snapshot needs about 390\.6 MB.*left the current server \(0\.1\.0\+aa01\) in place/u + ) + expect([...host.alive]).toEqual([OLD]) + expect(host.activeVersion()).toBe(OLD) + expect(host.commands.some((command) => command.includes('kill -TERM'))).toBe(false) + expect(host.journal).toBeNull() + expect(host.fence).toBe(false) + }) + + it('counts free space and reclaimable snapshots apart in the refusal', async () => { + answerRoom(400_000, 300_000, 1_000) + + expect(JSON.stringify(await deploy())).toMatch( + /has 1000 KB free \(plus 293\.0 MB Orca would reclaim from earlier snapshots\)/u + ) + }) + + it('keeps the snapshot the activation record names through both the room check and the prune', async () => { + const recorded = 'pre-0.1.0+aa01-500' + host.record = JSON.stringify( + withActivatedVersion( + emptyOrcadActivationRecord(), + OLD, + { + dirName: recorded, + takenBeforeVersion: OLD, + readableByVersion: null, + takenAt: new Date(500).toISOString() + }, + new Date(0) + ) + ) + answerRoom(400_000, 0, 10_000_000) + + expect(await deploy()).toMatchObject({ outcome: 'installed-and-activated' }) + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.find((command) => command.includes('SNAPSHOT_SPACE'))).toContain( + `('${recorded}')` + ) + expect(commands.find(isSnapshotCaptureCommand)).toMatch( + new RegExp( + `\\('pre-${NEW.replace('+', '\\+')}-\\d+'\\|'${recorded.replace('+', '\\+')}'\\)`, + 'u' + ) + ) + }) + + it('measures room before the last terminal recount, so the scan cannot outdate it', async () => { + answerRoom(400_000, 0, 10_000_000) + const probedBeforeRecount: boolean[] = [] + const recountCensus = vi.fn(async () => { + probedBeforeRecount.push( + vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('SNAPSHOT_SPACE')) + ) + return { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + }) + + expect(await deploy({ recountCensus })).toMatchObject({ outcome: 'installed-and-activated' }) + expect(probedBeforeRecount).toEqual([true]) + }) + + it('goes ahead when the room probe loses its channel, leaving the capture to decide', async () => { + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('SNAPSHOT_SPACE')) { + throw Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) + } + return host.exec(command) + }) + + expect(await deploy()).toMatchObject({ outcome: 'installed-and-activated' }) + expect(host.activeVersion()).toBe(NEW) + }) + + it('still stops on a lost fence from the room probe', async () => { + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('SNAPSHOT_SPACE')) { + throw Object.assign(new Error('fence lost'), { + exitCode: 75, + stdout: '__ORCAD_FENCE_LOST__\n' + }) + } + return host.exec(command) + }) + + await expect(deploy()).rejects.toBeInstanceOf(OrcadFenceLostError) + expect([...host.alive]).toEqual([OLD]) + expect(host.commands.some((command) => command.includes('kill -TERM'))).toBe(false) + }) + + it('updates when pruning earlier snapshots makes the room', async () => { + answerRoom(400_000, 500_000, 1_000) + + expect(await deploy()).toMatchObject({ outcome: 'installed-and-activated' }) + expect(host.activeVersion()).toBe(NEW) + }) +}) diff --git a/src/main/ssh/orcad-snapshot-room.ts b/src/main/ssh/orcad-snapshot-room.ts new file mode 100644 index 000000000000..d3ff89bb8520 --- /dev/null +++ b/src/main/ssh/orcad-snapshot-room.ts @@ -0,0 +1,67 @@ +/** Whether the pre-activation snapshot fits, asked before the running server is stopped (#30491). */ +import { orcadRemoteBaseDir } from '../managed-server/install/orcad-remote-windows-node' +import { execOrcadRemoteOr } from '../managed-server/install/orcad-remote-runtime-control' +import { + ORCAD_STATE_SNAPSHOT_DIR, + type OrcadActivationRecord +} from '../managed-server/activation/orcad-activation-record' +import { + orcadSnapshotShortfallKb, + orcadSnapshotShortfallMessage, + orcadSnapshotSpaceCommand, + parseOrcadSnapshotSpace +} from '../managed-server/host-state/orcad-state-snapshot-space' +import { joinRemotePath } from './ssh-remote-platform' +import { isUnconfirmedSshCommandTermination } from './remote-exec/remote-exec-command' +import type { OrcadSlotOptions } from './orcad-recovery-slot' + +export const ORCAD_SNAPSHOT_SPACE_INSUFFICIENT_CODE = 'orcad_snapshot_space_insufficient' + +export function orcadSnapshotsRoot(options: Pick): string { + return joinRemotePath( + options.host, + orcadRemoteBaseDir(options.host, options.remoteHome), + ORCAD_STATE_SNAPSHOT_DIR + ) +} + +/** The refusal to show when the snapshot cannot fit, or null to go ahead (an unread answer too). */ +export async function orcadSnapshotRoomRefusal( + options: OrcadSlotOptions, + record: OrcadActivationRecord, + fullVersion: string +): Promise { + const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome) + const command = orcadSnapshotSpaceCommand( + options.host, + options.userDataDir, + orcadSnapshotsRoot(options), + baseDir, + record.snapshot ? [record.snapshot.dirName] : [] + ) + if (!command) { + return null + } + const space = parseOrcadSnapshotSpace(await measureRoom(options, command)) + const shortfall = orcadSnapshotShortfallKb(space) + return space && shortfall !== null + ? orcadSnapshotShortfallMessage(space, shortfall, { + userDataDir: options.userDataDir, + baseDir, + fullVersion, + active: record.active + }) + : null +} + +async function measureRoom(options: OrcadSlotOptions, command: string): Promise { + try { + return await execOrcadRemoteOr(options, command) + } catch (error) { + // A read-only probe that lost its channel only leaves the room unknown; the capture decides. + if (isUnconfirmedSshCommandTermination(error) && !options.signal?.aborted) { + return '' + } + throw error + } +} diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts index 93f5e9566d04..c3eac19d6b5c 100644 --- a/src/main/ssh/sftp-upload.test.ts +++ b/src/main/ssh/sftp-upload.test.ts @@ -1,11 +1,19 @@ import { spawnSync } from 'node:child_process' +import { EventEmitter } from 'node:events' import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' -import type { SFTPWrapper } from 'ssh2' -import { removeDirectorySftp, uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' +import type { SFTPWrapper, WriteStreamOptions } from 'ssh2' +import { SFTP } from 'ssh2/lib/protocol/SFTP.js' +import { + removeDirectorySftp, + SFTP_UPLOAD_IN_FLIGHT_BYTES, + uploadBuffer, + uploadDirectory, + uploadFile +} from './sftp-upload' function createWritable(): Writable { return new Writable({ @@ -57,7 +65,8 @@ describe('sftp-upload', () => { expect(sftp.mkdir).toHaveBeenCalledWith('/remote/assets/nested', expect.any(Function)) expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/nested/asset.txt', { - flags: 'wx' + flags: 'wx', + highWaterMark: SFTP_UPLOAD_IN_FLIGHT_BYTES }) const writeStream = vi.mocked(sftp.createWriteStream).mock.results[0]?.value as Writable expect(writeStream.listenerCount('close')).toBe(0) @@ -78,7 +87,8 @@ describe('sftp-upload', () => { expect(sftp.mkdir).toHaveBeenCalledWith('/remote/assets/..fixtures', expect.any(Function)) expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/..fixtures/asset.txt', { - flags: 'wx' + flags: 'wx', + highWaterMark: SFTP_UPLOAD_IN_FLIGHT_BYTES }) }) @@ -93,7 +103,8 @@ describe('sftp-upload', () => { await uploadDirectory(sftp, aliasDir, '/remote/assets') expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/asset.txt', { - flags: 'w' + flags: 'w', + highWaterMark: SFTP_UPLOAD_IN_FLIGHT_BYTES }) await rm(aliasDir, { force: true }) await rm(realDir, { recursive: true, force: true }) @@ -114,7 +125,8 @@ describe('sftp-upload', () => { await uploadDirectory(sftp, localDir, '/remote/assets', localDir) expect(sftp.createWriteStream).toHaveBeenCalledWith('/remote/assets/asset.txt', { - flags: 'w' + flags: 'w', + highWaterMark: SFTP_UPLOAD_IN_FLIGHT_BYTES }) } finally { await rm(linkedParent, { force: true }) @@ -281,3 +293,69 @@ describe('sftp-upload', () => { expect(sftp.rmdir).toHaveBeenNthCalledWith(2, '/remote/assets', expect.any(Function)) }) }) + +describe('uploadFile over a high-latency link', () => { + // ssh2's own WriteStream over a fake session whose every request takes one "round trip". + function slowSftp(roundTripMs: number): { + sftp: SFTPWrapper + written: Map + maxInFlight: () => number + } { + let inFlight = 0 + let peak = 0 + const written = new Map() + const later = (cb: () => void): void => { + setTimeout(cb, roundTripMs) + } + const session = Object.assign(new EventEmitter(), { + server: false, + open: (_path: string, _flags: string, _mode: number, cb: (e: null, h: Buffer) => void) => + later(() => cb(null, Buffer.from('h'))), + fchmod: (_h: Buffer, _mode: number, cb: (e: null) => void) => later(() => cb(null)), + write: ( + _h: Buffer, + buf: Buffer, + off: number, + len: number, + pos: number, + cb: (e: null, n: number) => void + ) => { + inFlight += 1 + peak = Math.max(peak, inFlight) + written.set(pos, Buffer.from(buf.subarray(off, off + len))) + later(() => { + inFlight -= 1 + cb(null, len) + }) + }, + close: (_h: Buffer, cb: (e: null) => void) => later(() => cb(null)) + }) + const sftp = Object.assign(session, { + createWriteStream: (path: string, options: WriteStreamOptions) => + SFTP.prototype.createWriteStream.call(session, path, options) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: uploadFile only calls createWriteStream, which drives open/fchmod/write/close above. + return { sftp: sftp as unknown as SFTPWrapper, written, maxInFlight: () => peak } + } + + it('keeps many writes in flight instead of one chunk per round trip', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) + const contents = Buffer.alloc(4 * 1024 * 1024) + for (let i = 0; i < contents.length; i += 4096) { + contents[i] = i % 251 + } + await writeFile(join(dir, 'node.tar.gz'), contents) + const { sftp, written, maxInFlight } = slowSftp(20) + try { + await uploadFile(sftp, join(dir, 'node.tar.gz'), '/remote/node.tar.gz') + // One 64 KiB read chunk per round trip would keep a single write in flight. + expect(maxInFlight()).toBeGreaterThanOrEqual(8) + const reassembled = Buffer.concat( + [...written.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk) + ) + expect(reassembled.equals(contents)).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index 9cb16bd82ff6..5ae6846de6ec 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -7,6 +7,11 @@ import { promisify } from 'node:util' import type { SFTPWrapper } from 'ssh2' import { latchLateSftpStreamErrors, type SftpStreamErrorLatch } from './sftp-stream-late-error' +// Why: ssh2's WriteStream waits for each chunk's ack before writing the next, so an upload moves +// one chunk per round trip. Buffering this much lets its _writev keep that many bytes of WRITE +// requests in flight, as fastPut's default 64 x 32 KiB does. +export const SFTP_UPLOAD_IN_FLIGHT_BYTES = 2 * 1024 * 1024 + export function mkdirSftp( sftp: SFTPWrapper, path: string, @@ -59,7 +64,8 @@ export async function uploadFile( } // Why: rejected local sources must not leave an empty remote file. writeStream = sftp.createWriteStream(remotePath, { - flags: options?.exclusive ? 'wx' : 'w' + flags: options?.exclusive ? 'wx' : 'w', + highWaterMark: SFTP_UPLOAD_IN_FLIGHT_BYTES }) // Why: the OPEN reply can land after this transfer settles; without a listener that // outlives it, ssh2 throws it synchronously into the socket handler (#15479). diff --git a/src/main/ssh/ssh-exec-command-decoder-fallback.test.ts b/src/main/ssh/ssh-exec-command-decoder-fallback.test.ts index 791012befa95..e700b8cea7d8 100644 --- a/src/main/ssh/ssh-exec-command-decoder-fallback.test.ts +++ b/src/main/ssh/ssh-exec-command-decoder-fallback.test.ts @@ -87,7 +87,7 @@ describe.skipIf(process.platform === 'win32')( expect(conn.exec).toHaveBeenCalledTimes(1) }) - it('captures a snapshot through a gateway that truncates the plain command and merges stderr', async () => { + it('captures a snapshot whole through a gateway that cuts exec requests at 6 KiB and merges stderr', async () => { const base = mkdtempSync(join(tmpdir(), 'orcad-gateway-capture-')) try { const root = join(base, '.orca') @@ -100,20 +100,11 @@ describe.skipIf(process.platform === 'win32')( snapshot, join(base, '.orca-remote') ) - // The command embeds the temp path ~40 times, so its size varies by OS; pick a cut the - // plain form always exceeds and the compressed form always fits. - const plainBytes = Buffer.byteLength( - wrapRemoteCommandForPosixShell(command, { compress: false }) - ) - const compressedBytes = Buffer.byteLength( - wrapRemoteCommandForPosixShell(command, { compress: true }) - ) - const gatewayLimitBytes = Math.min( - 10 * 1024, - Math.floor((plainBytes + compressedBytes) / 2) - ) - expect(plainBytes).toBeGreaterThan(gatewayLimitBytes) - expect(compressedBytes).toBeLessThan(gatewayLimitBytes) + // Under the byte budget, so even uncompressed nothing is cut. + const gatewayLimitBytes = 6 * 1024 + expect( + Buffer.byteLength(wrapRemoteCommandForPosixShell(command, { compress: false })) + ).toBeLessThan(gatewayLimitBytes) const output = await execCommand(host({ gatewayLimitBytes }), command) diff --git a/src/main/ssh/ssh-host-key-decision.test.ts b/src/main/ssh/ssh-host-key-decision.test.ts index 1724b4205840..a5ae32d9d872 100644 --- a/src/main/ssh/ssh-host-key-decision.test.ts +++ b/src/main/ssh/ssh-host-key-decision.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from 'vitest' -import { decideHostKey, type HostKeyDecisionInput } from './ssh-host-key-decision' +import { + decideHostKey, + hostKeyPolicyForOwner, + type HostKeyDecisionInput +} from './ssh-host-key-decision' function input(overrides: Partial = {}): HostKeyDecisionInput { return { knownHostsOutcome: 'unknown', storeOutcome: 'unknown', strictHostKeyChecking: 'ask', - isEphemeralRuntimeTarget: false, + policy: { kind: 'standard' }, siteConfigSuppressed: false, knownHostsUnreadable: false, displayHost: 'build-01', @@ -311,12 +315,12 @@ describe('deciding what to do with a presented host key', () => { // A fresh VM presents a new key every launch, so recording one would accumulate a row per // launch and eventually turn a stale record into a spurious mismatch. it('accepts an ephemeral runtime target without remembering it', () => { - expect(decideHostKey(input({ isEphemeralRuntimeTarget: true })).action).toBe('accept') + expect(decideHostKey(input({ policy: { kind: 'ephemeral' } })).action).toBe('accept') }) it('still rejects a changed key for an ephemeral target', () => { const decision = decideHostKey( - input({ isEphemeralRuntimeTarget: true, knownHostsOutcome: 'mismatch' }) + input({ policy: { kind: 'ephemeral' }, knownHostsOutcome: 'mismatch' }) ) expect(decision.action).toBe('reject') }) @@ -341,7 +345,7 @@ describe('deciding what to do with a presented host key', () => { // runtimes off for everyone whose HOME diverges from their passwd home. it('still accepts an ephemeral target when a source could not be read', () => { const decision = decideHostKey( - input({ isEphemeralRuntimeTarget: true, siteConfigSuppressed: true }) + input({ policy: { kind: 'ephemeral' }, siteConfigSuppressed: true }) ) expect(decision.action).toBe('accept') }) @@ -350,7 +354,7 @@ describe('deciding what to do with a presented host key', () => { // actually read and the user actually asked for, so it outranks the carve-out. it('denies an ephemeral target under an explicit StrictHostKeyChecking=yes', () => { const decision = decideHostKey( - input({ isEphemeralRuntimeTarget: true, strictHostKeyChecking: 'yes' }) + input({ policy: { kind: 'ephemeral' }, strictHostKeyChecking: 'yes' }) ) expect(decision.action).toBe('reject') }) @@ -359,7 +363,7 @@ describe('deciding what to do with a presented host key', () => { it('still rejects a changed key for an ephemeral target with sources incomplete', () => { const decision = decideHostKey( input({ - isEphemeralRuntimeTarget: true, + policy: { kind: 'ephemeral' }, siteConfigSuppressed: true, storeOutcome: 'mismatch' }) @@ -382,7 +386,7 @@ describe('deciding what to do with a presented host key', () => { { storeOutcome: 'mismatch' }, { strictHostKeyChecking: 'yes' }, { strictHostKeyChecking: 'no' }, - { isEphemeralRuntimeTarget: true }, + { policy: { kind: 'ephemeral' } }, { siteConfigSuppressed: true }, { knownHostsUnreadable: true } ] @@ -391,3 +395,15 @@ describe('deciding what to do with a presented host key', () => { } }) }) + +describe('hostKeyPolicyForOwner', () => { + it('trusts first contact only for ephemeral runtime VMs', () => { + expect(hostKeyPolicyForOwner({ type: 'on-demand-runtime', runtimeId: 'vm-1' })).toEqual({ + kind: 'ephemeral' + }) + expect( + hostKeyPolicyForOwner({ type: 'on-demand-runtime', runtimeId: 'managed-orcad:env-1' }) + ).toEqual({ kind: 'standard' }) + expect(hostKeyPolicyForOwner(undefined)).toEqual({ kind: 'standard' }) + }) +}) diff --git a/src/main/ssh/ssh-host-key-decision.ts b/src/main/ssh/ssh-host-key-decision.ts index 02707dcade9a..e56c83ff34e3 100644 --- a/src/main/ssh/ssh-host-key-decision.ts +++ b/src/main/ssh/ssh-host-key-decision.ts @@ -5,6 +5,8 @@ * rather than importing its sources so a test states its own trust state instead of writing files. * See docs/reference/ssh-host-key-verification.md. */ +import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner' +import type { SshTargetOwner } from '../../shared/ssh-types' import type { KnownHostsOutcome } from './config/ssh-known-hosts' /** Phase 1 ships no dialog; `prompt` is reserved for Phase 2 and never produced today. */ @@ -19,6 +21,18 @@ export type HostKeyDecision = { reason?: string } +export type HostKeyPolicy = + | { kind: 'standard' } + /** + * A freshly provisioned VM presents a new key every launch, so first contact is expected rather + * than suspicious โ€” and persisting would accumulate a record per launch. + */ + | { kind: 'ephemeral' } + +export function hostKeyPolicyForOwner(owner: SshTargetOwner | undefined): HostKeyPolicy { + return isEphemeralRuntimeSshOwner(owner) ? { kind: 'ephemeral' } : { kind: 'standard' } +} + export type HostKeyDecisionInput = { /** From the user's known_hosts files, unioned. */ knownHostsOutcome: KnownHostsOutcome @@ -26,11 +40,8 @@ export type HostKeyDecisionInput = { storeOutcome: 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown' /** Effective `StrictHostKeyChecking`; anything unrecognised is treated as `ask`. */ strictHostKeyChecking: string - /** - * A freshly provisioned VM presents a new key every launch, so first contact is expected rather - * than suspicious โ€” and persisting would accumulate a record per launch. - */ - isEphemeralRuntimeTarget: boolean + /** How this target's keys are trusted, decided by who owns the target. */ + policy: HostKeyPolicy /** * `ssh -G` ran on the HOME-divergent `-F` path, which suppresses /etc/ssh/ssh_config, so a * site-wide StrictHostKeyChecking may exist that we cannot see. We refuse a NEW host rather than @@ -137,7 +148,7 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision { const { knownHostsOutcome, storeOutcome, - isEphemeralRuntimeTarget, + policy, siteConfigSuppressed, knownHostsUnreadable, displayHost, @@ -255,7 +266,7 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision { // Accepting without recording is the rest of it: a new key every launch would otherwise grow a // row per VM, and a stale row eventually reads as a mismatch against a host that did nothing // wrong. - if (isEphemeralRuntimeTarget) { + if (policy.kind === 'ephemeral') { return { action: 'accept', outcome: unknownOutcome } } if (siteConfigSuppressed) { diff --git a/src/main/ssh/ssh-host-key-verifier.test.ts b/src/main/ssh/ssh-host-key-verifier.test.ts index 01cb2a622964..e5d66189eab3 100644 --- a/src/main/ssh/ssh-host-key-verifier.test.ts +++ b/src/main/ssh/ssh-host-key-verifier.test.ts @@ -21,7 +21,7 @@ function deps(overrides: Partial = {}): HostKeyVerifierDeps port: 22, displayHost: 'example.com', strictHostKeyChecking: 'ask', - isEphemeralRuntimeTarget: false, + policy: { kind: 'standard' }, siteConfigSuppressed: false, knownHostsUnreadable: false, entries: [], @@ -121,7 +121,7 @@ describe('the ssh2 host key verifier', () => { it.each([ ['a key we already know', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) }], ['a rejected key', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`) }], - ['an ephemeral runtime target', { isEphemeralRuntimeTarget: true }], + ['an ephemeral runtime target', { policy: { kind: 'ephemeral' as const } }], ['a lax StrictHostKeyChecking', { strictHostKeyChecking: 'no' }] ])('does not record %s', (_label, overrides) => { const rememberHostKey = vi.fn() diff --git a/src/main/ssh/ssh-host-key-verifier.ts b/src/main/ssh/ssh-host-key-verifier.ts index 65da407ee840..9fcd91ba4817 100644 --- a/src/main/ssh/ssh-host-key-verifier.ts +++ b/src/main/ssh/ssh-host-key-verifier.ts @@ -15,7 +15,7 @@ import { type KnownHostsEntry, type KnownHostsOutcome } from './config/ssh-known-hosts' -import { decideHostKey, type HostKeyDecision } from './ssh-host-key-decision' +import { decideHostKey, type HostKeyDecision, type HostKeyPolicy } from './ssh-host-key-decision' export type TrustedHostKeyLookup = (query: { host: string @@ -31,7 +31,7 @@ export type HostKeyVerifierDeps = { /** True when `host` came from HostKeyAlias; ssh looks those up without the port. */ isHostKeyAlias?: boolean strictHostKeyChecking: string - isEphemeralRuntimeTarget: boolean + policy: HostKeyPolicy siteConfigSuppressed: boolean knownHostsUnreadable: boolean /** Already unioned across every known_hosts file. */ @@ -222,7 +222,7 @@ export function createHostKeyVerifier( }), storeOutcome: deps.isTrusted({ host: deps.host, port: deps.port, keyType, key }), strictHostKeyChecking: deps.strictHostKeyChecking, - isEphemeralRuntimeTarget: deps.isEphemeralRuntimeTarget, + policy: deps.policy, siteConfigSuppressed: deps.siteConfigSuppressed, knownHostsUnreadable: deps.knownHostsUnreadable, displayHost: deps.displayHost, diff --git a/src/main/ssh/ssh-host-unserved-status.ts b/src/main/ssh/ssh-host-unserved-status.ts index b1fe7611880d..39f809ac01a6 100644 --- a/src/main/ssh/ssh-host-unserved-status.ts +++ b/src/main/ssh/ssh-host-unserved-status.ts @@ -23,6 +23,9 @@ export function unservedServerStatus( const HELD_MOVE_MESSAGE = 'Orca paused moving this hostโ€™s projects to its managed server because it couldnโ€™t confirm the serverโ€™s copy. Nothing is lost: the host keeps its projects and stays locked until the move finishes. Connect again to resume. If the server canโ€™t come back, stop or forget it in Settings > Managed servers.' +const NOEXEC_MESSAGE = + 'This host doesnโ€™t allow running programs from ~/.orca-remote, so Orca canโ€™t start its server there. This usually means the home folder is mounted with noexec. Ask the hostโ€™s administrator to allow running programs there, then connect again.' + /** Untranslated twin of the renderer's status line, for logs and callers without the catalog. */ export function unservedHostServerMessage(decision: UnservedDecision): string { if (decision.held && (decision.reason === 'failed' || decision.reason === 'deferred')) { @@ -30,7 +33,9 @@ export function unservedHostServerMessage(decision: UnservedDecision): string { } switch (decision.reason) { case 'orcad_unavailable': - return 'This SSH host isnโ€™t supported by this version of Orca. To keep using it, install an older version of Orca.' + return decision.detail === 'runtime_noexec' + ? NOEXEC_MESSAGE + : 'This SSH host isnโ€™t supported by this version of Orca. To keep using it, install an older version of Orca.' case 'relay_terminals_live': case 'relay_terminals_unverifiable': return 'Terminals started by an older version of Orca are still running on this host, and they donโ€™t stop on their own. Close them in that version of Orca or end them on the host, then reconnect.' diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 10bee1658f11..8acbe497b1d1 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -150,8 +150,9 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { state.runtime = runtime runtime.attachAgentStatusStore(agentHookServer) agentHookServer.subscribeEnrichedStatus((enriched) => - recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) + recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched, runtime) ) + agentHookServer.setCliWriterExecutionMatcher(observedPaneIdentities, runtime) // Switch point: main keeps the resume record of each pane it holds from the status it accepts. if (ORCA_LAYOUT_OWNER_IS_MAIN) { agentHookServer.subscribeEnrichedStatus((enriched) => diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 5366b35bfb77..94d15288a97b 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -94,13 +94,13 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} state.isQuitting = false clearExpectedRendererReload() }, - onRendererProcessGone: (details, webContentsId) => { + onRendererProcessGone: (details, webContentsId, liveness) => { recordProcessGoneCrash( 'renderer', 'renderer', details.reason, details.exitCode ?? null, - { processType: 'renderer' }, + { processType: 'renderer', ...liveness }, webContentsId ) // Why: launch-failed only says a spawn failed; the probe's errno (EAGAIN = process limit) names the cause. diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index 0f22c3cc644e..ff33142454f6 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -100,7 +100,14 @@ describe('createMainWindow', () => { isLoadingMainFrame: vi.fn(() => false), mainFrame: {}, on: vi.fn((event, handler) => { - windowHandlers[event] = handler + // Chain like an EventEmitter: several lifecycle modules listen to the same event. + const previous = windowHandlers[event] + windowHandlers[event] = previous + ? (...args: unknown[]) => { + previous(...args) + handler(...args) + } + : handler }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), @@ -245,7 +252,11 @@ describe('createMainWindow', () => { const details = { reason: 'crashed', exitCode: 5 } as Electron.RenderProcessGoneDetails windowHandlers['render-process-gone']?.({} as never, details) - expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142) + expect(onRendererProcessGone).toHaveBeenCalledWith( + details, + 142, + expect.objectContaining({ rendererDocumentLoaded: false }) + ) }) it('passes the renderer webContents id through crash recording and recovery callbacks', () => { @@ -293,7 +304,11 @@ describe('createMainWindow', () => { windowHandlers['render-process-gone']?.({} as never, details) vi.advanceTimersByTime(250) - expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424) + expect(onRendererProcessGone).toHaveBeenCalledWith( + details, + 424, + expect.objectContaining({ rendererDocumentLoaded: false }) + ) expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424) } finally { consoleError.mockRestore() @@ -344,7 +359,8 @@ describe('createMainWindow', () => { expect(onRendererProcessGone).toHaveBeenCalledWith( expect.objectContaining({ reason: 'killed', exitCode: 15 }), - expect.any(Number) + expect.any(Number), + expect.any(Object) ) consoleError.mockRestore() diff --git a/src/main/window/main-window-contracts.ts b/src/main/window/main-window-contracts.ts index 2b2e82823042..5b6a6e7dd1ec 100644 --- a/src/main/window/main-window-contracts.ts +++ b/src/main/window/main-window-contracts.ts @@ -5,6 +5,7 @@ import type { RecoveryReloadMilestone, RecoveryReloadTrigger } from './renderer-recovery-reload-watchdog' +import type { RendererLivenessAtDeath } from './renderer-liveness-at-death' /** Per-load outcome from Electron's load promise, which is scoped to that one load unlike `did-finish-load`. */ export type MainWindowLoadObserver = { @@ -19,7 +20,8 @@ export type CreateMainWindowOptions = { onQuitAborted?: () => void onRendererProcessGone?: ( details: Electron.RenderProcessGoneDetails, - webContentsId: number + webContentsId: number, + liveness: RendererLivenessAtDeath ) => void /** Returns true when Orca should reload after renderer loss; update-relaunch/quit tear down children intentionally, so don't fight shutdown. */ shouldRecoverRenderer?: ( diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index 6b0f1fb1002c..5868d05d3c03 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -24,6 +24,7 @@ import { import { registerRendererDocumentNavigation } from './renderer-document-navigation' import { createRendererRecoveryReloadWatchdog } from './renderer-recovery-reload-watchdog' import { createRendererLaunchFailureBackoff } from './renderer-launch-failure-backoff' +import { trackRendererLivenessAtDeath } from './renderer-liveness-at-death' export type MainWindowFocusLifecycle = { dispose: () => void @@ -245,7 +246,9 @@ export function installMainWindowFocusLifecycle(args: { recoveryReloadWatchdog.issue(details, recovery.recentRecoveryCount) }, launchRetryDelayMs ?? 250) } + const rendererLiveness = trackRendererLivenessAtDeath(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { + const liveness = rendererLiveness.takeAtRendererGone() rendererProcessGone = true retireBrowserClientPageRenderer(rendererWebContents) browserRouteWebContentsRegistry.retireRenderer(rendererWebContentsId) @@ -256,7 +259,7 @@ export function installMainWindowFocusLifecycle(args: { // Why: macOS reports BrowserWindow teardown as renderer killed/SIGKILL after close โ€” window noise, not a crash. if (!isWindowClosing()) { // Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable. - opts?.onRendererProcessGone?.(details, rendererWebContentsId) + opts?.onRendererProcessGone?.(details, rendererWebContentsId, liveness) } if (!isWindowClosing()) { console.error('[window] Renderer process gone; close confirmation will be bypassed', details) diff --git a/src/main/window/renderer-liveness-at-death.test.ts b/src/main/window/renderer-liveness-at-death.test.ts new file mode 100644 index 000000000000..327296e3a44b --- /dev/null +++ b/src/main/window/renderer-liveness-at-death.test.ts @@ -0,0 +1,138 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const recordCoalescedDurableCrashBreadcrumb = vi.fn() +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordCoalescedDurableCrashBreadcrumb: (...args: unknown[]) => + recordCoalescedDurableCrashBreadcrumb(...args) +})) + +import { trackRendererLivenessAtDeath } from './renderer-liveness-at-death' + +function createWindow(state: { minimized?: boolean; visible?: boolean; destroyed?: boolean } = {}) { + const contents = new EventEmitter() + const window = Object.assign(new EventEmitter(), { + webContents: contents, + isDestroyed: () => state.destroyed === true, + isMinimized: () => state.minimized === true, + isVisible: () => state.visible !== false, + isFocused: () => false + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tracker only reads the emitter API and the four getters stubbed above. + return { window: window as unknown as Electron.BrowserWindow, contents } +} + +const MAIN_FRAME_NAVIGATION = { isMainFrame: true, isSameDocument: false } + +describe('trackRendererLivenessAtDeath', () => { + let now = 0 + beforeEach(() => { + now = 1_000 + recordCoalescedDurableCrashBreadcrumb.mockClear() + }) + + it('reports a document that never loaded as a blank window with its age', () => { + const { window, contents } = createWindow() + const tracker = trackRendererLivenessAtDeath(window, () => now) + contents.emit('did-start-navigation', MAIN_FRAME_NAVIGATION) + now += 866_000 + + expect(tracker.takeAtRendererGone()).toEqual({ + rendererDocumentLoaded: false, + rendererDocumentAgeMs: 866_000, + mainWindowState: 'visible', + mainWindowFocused: false + }) + }) + + it('reports how long the renderer has been hung, and clears it on responsive', () => { + const { window, contents } = createWindow({ minimized: true }) + const tracker = trackRendererLivenessAtDeath(window, () => now) + contents.emit('did-finish-load') + window.emit('unresponsive') + now += 5_000 + window.emit('unresponsive') + now += 40_000 + + expect(recordCoalescedDurableCrashBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ name: 'renderer_unresponsive' }) + ) + window.emit('responsive') + expect(recordCoalescedDurableCrashBreadcrumb).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'renderer_responsive', data: { hungForMs: 45_000 } }) + ) + expect(tracker.takeAtRendererGone().rendererUnresponsiveForMs).toBeUndefined() + + window.emit('unresponsive') + now += 30_000 + expect(tracker.takeAtRendererGone()).toMatchObject({ + rendererUnresponsiveForMs: 30_000, + rendererDocumentLoaded: true, + mainWindowState: 'minimized' + }) + }) + + it('does not carry a hang into the next document after a recovery reload', () => { + const { window, contents } = createWindow() + const tracker = trackRendererLivenessAtDeath(window, () => now) + window.emit('unresponsive') + contents.emit('did-start-navigation', MAIN_FRAME_NAVIGATION) + + expect(tracker.takeAtRendererGone().rendererUnresponsiveForMs).toBeUndefined() + }) + + it('omits window state instead of throwing for a destroyed window', () => { + const { window } = createWindow({ destroyed: true }) + const tracker = trackRendererLivenessAtDeath(window, () => now) + + expect(tracker.takeAtRendererGone()).toEqual({ + rendererDocumentLoaded: false, + rendererDocumentAgeMs: 0 + }) + }) + + it('reports a failed main-frame load as not loaded, though did-finish-load follows it', () => { + const { window, contents } = createWindow() + const tracker = trackRendererLivenessAtDeath(window, () => now) + contents.emit('did-start-navigation', MAIN_FRAME_NAVIGATION) + contents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///missing', true) + contents.emit('did-finish-load') + now += 12_000 + + expect(tracker.takeAtRendererGone()).toMatchObject({ + rendererDocumentLoaded: false, + rendererDocumentLoadFailed: true, + rendererDocumentAgeMs: 12_000 + }) + }) + + it('ignores aborted loads, subframe failures and same-document navigations', () => { + const { window, contents } = createWindow() + const tracker = trackRendererLivenessAtDeath(window, () => now) + contents.emit('did-start-navigation', MAIN_FRAME_NAVIGATION) + contents.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'file:///app', true) + contents.emit('did-fail-load', {}, -6, 'ERR_FILE_NOT_FOUND', 'file:///frame', false) + contents.emit('did-finish-load') + now += 5_000 + contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + contents.emit('did-start-navigation', { isMainFrame: false, isSameDocument: false }) + + const state = tracker.takeAtRendererGone() + expect(state).toMatchObject({ rendererDocumentLoaded: true, rendererDocumentAgeMs: 5_000 }) + expect(state.rendererDocumentLoadFailed).toBeUndefined() + }) + + it('does not log the replacement renderer as the dead one recovering', () => { + const { window, contents } = createWindow() + const tracker = trackRendererLivenessAtDeath(window, () => now) + contents.emit('did-finish-load') + window.emit('unresponsive') + now += 20_000 + expect(tracker.takeAtRendererGone().rendererUnresponsiveForMs).toBe(20_000) + recordCoalescedDurableCrashBreadcrumb.mockClear() + + window.emit('responsive') + + expect(recordCoalescedDurableCrashBreadcrumb).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/renderer-liveness-at-death.ts b/src/main/window/renderer-liveness-at-death.ts new file mode 100644 index 000000000000..e16ad547880b --- /dev/null +++ b/src/main/window/renderer-liveness-at-death.ts @@ -0,0 +1,141 @@ +import type { BrowserWindow } from 'electron' +import { recordCoalescedDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' + +/** + * What the main window looked like to the user when its renderer died. + * + * Why: Windows `killed`/exit 1 reports are identical whether Task Manager, an EDR or + * Orca held the knife. A renderer that had been hung for minutes, or a document + * that never loaded (the post-update blank frame), turns "outside kill" into "the + * user ended a frozen Orca" โ€” and that is an Orca bug to chase, not noise. + */ +export type RendererLivenessAtDeath = { + /** Set only while Chromium flags the renderer hung (needs ~15s of unacknowledged input); absent = not detected hung. */ + rendererUnresponsiveForMs?: number + /** False when the current document never finished loading, or its load failed (blank window). */ + rendererDocumentLoaded: boolean + /** Present when the current main-frame load failed; Electron still fires did-finish-load after it. */ + rendererDocumentLoadFailed?: true + /** Age of the current document, so "never loaded" reads as seconds or minutes. */ + rendererDocumentAgeMs: number + /** Absent when the window was already destroyed. */ + mainWindowState?: 'visible' | 'minimized' | 'hidden' + mainWindowFocused?: boolean +} + +// Why no dispose: every listener lives on the window or its webContents and dies with them. +export type RendererLivenessTracker = { + /** Reads the state at a renderer death and clears the hang, so the replacement renderer's + * `responsive` is not logged as the dead one recovering. */ + takeAtRendererGone: () => RendererLivenessAtDeath +} + +// Why coalesced: a renderer that flaps hung/responsive must not evict the 30-slot ring. +const UNRESPONSIVE_CRUMB_MIN_INTERVAL_MS = 60_000 + +export function trackRendererLivenessAtDeath( + window: BrowserWindow, + now: () => number = Date.now +): RendererLivenessTracker { + const contents = window.webContents + let unresponsiveSince: number | null = null + let documentLoaded = false + let documentLoadFailed = false + let documentStartedAt = now() + + const onUnresponsive = (): void => { + unresponsiveSince ??= now() + recordCoalescedDurableCrashBreadcrumb({ + name: 'renderer_unresponsive', + coalesceKey: 'main-window-renderer-unresponsive', + minIntervalMs: UNRESPONSIVE_CRUMB_MIN_INTERVAL_MS + }) + } + const onResponsive = (): void => { + if (unresponsiveSince === null) { + return + } + const hungForMs = now() - unresponsiveSince + unresponsiveSince = null + recordCoalescedDurableCrashBreadcrumb({ + name: 'renderer_responsive', + data: { hungForMs }, + coalesceKey: 'main-window-renderer-responsive', + minIntervalMs: UNRESPONSIVE_CRUMB_MIN_INTERVAL_MS + }) + } + // Why start, not did-navigate: a failed load never commits, so did-navigate would leave the + // previous document's state in place for the blank window this exists to catch. + const onNavigationStart = (details?: { + isMainFrame?: boolean + isSameDocument?: boolean + }): void => { + // Why optional: a throwing listener would stop the emitter's later navigation listeners. + if (!details?.isMainFrame || details.isSameDocument) { + return + } + unresponsiveSince = null + documentLoaded = false + documentLoadFailed = false + documentStartedAt = now() + } + const onLoadFailed = ( + _event: unknown, + errorCode: number, + _description: string, + _url: string, + isMainFrame: boolean + ): void => { + // -3 (ERR_ABORTED) is a superseded navigation, not a failed document. + if (isMainFrame && errorCode !== -3) { + documentLoadFailed = true + } + } + const onLoaded = (): void => { + documentLoaded = !documentLoadFailed + } + window.on('unresponsive', onUnresponsive) + window.on('responsive', onResponsive) + contents.on('did-start-navigation', onNavigationStart) + contents.on('did-fail-load', onLoadFailed) + contents.on('did-finish-load', onLoaded) + + return { + takeAtRendererGone: () => { + const at = now() + const state: RendererLivenessAtDeath = { + ...(unresponsiveSince === null + ? {} + : { rendererUnresponsiveForMs: at - unresponsiveSince }), + rendererDocumentLoaded: documentLoaded, + ...(documentLoadFailed ? { rendererDocumentLoadFailed: true } : {}), + rendererDocumentAgeMs: at - documentStartedAt, + ...readWindowState(window) + } + unresponsiveSince = null + return state + } + } +} + +function readWindowState( + window: BrowserWindow +): Pick { + // Why never throw: this runs inside render-process-gone ahead of recovery + // scheduling, and a window dying mid-close throws on every getter. + try { + if (window.isDestroyed()) { + return {} + } + return { + mainWindowState: window.isMinimized() + ? 'minimized' + : window.isVisible() + ? 'visible' + : 'hidden', + mainWindowFocused: window.isFocused() + } + } catch { + return {} + } +} diff --git a/src/packages/process-host/src/process-spec.ts b/src/packages/process-host/src/process-spec.ts index 8e0202e43eae..20c9a3838d5e 100644 --- a/src/packages/process-host/src/process-spec.ts +++ b/src/packages/process-host/src/process-spec.ts @@ -44,6 +44,8 @@ export type ProcessSpec = { terminationBarrier?: boolean | ProcessTerminationBarrier /** Called once when the child exits or tree termination is verified. */ onChildTerminated?: () => void + /** Every raw stderr chunk as it arrives, including any past the capture cap. */ + onStderr?: (chunk: Buffer | string) => void } export type PipedChildProcess = ChildProcessWithoutNullStreams diff --git a/src/packages/process-host/src/run-process.test.ts b/src/packages/process-host/src/run-process.test.ts index b91fb40c433f..58132ef6441b 100644 --- a/src/packages/process-host/src/run-process.test.ts +++ b/src/packages/process-host/src/run-process.test.ts @@ -162,6 +162,20 @@ describe('bounded output', () => { }) }) +describe('stderr streaming', () => { + it('forwards stderr as it arrives without changing the captured result', async () => { + const chunks: string[] = [] + const result = await runProcess({ + program: process.execPath, + args: ['-e', 'process.stderr.write("x".repeat(64))'], + maxOutputBytes: 8, + onStderr: (chunk) => chunks.push(chunk.toString()) + }) + expect(chunks.join('')).toBe('x'.repeat(64)) + expect(result.stderr).toBe('xxxxxxxx') + }) +}) + describe('unkillable children', () => { it('settles after the grace period rather than outliving its own deadline', async () => { // `close` only fires once the child is gone, so a child that ignores the diff --git a/src/packages/process-host/src/run-process.ts b/src/packages/process-host/src/run-process.ts index 68fc06f9ff03..0a4f523b333d 100644 --- a/src/packages/process-host/src/run-process.ts +++ b/src/packages/process-host/src/run-process.ts @@ -121,6 +121,7 @@ export function runProcess( }) child.stderr?.on('data', (chunk: Buffer | string) => { stderr.write(chunk) + spec.onStderr?.(chunk) if (spec.killOnOutputLimit && stderr.truncated()) { stopAndSettle() } diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index ad516b32265f..4c94ee7a3da8 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -127,7 +127,7 @@ export type BrowserApi = { onAgentInputFocusGuard: ( callback: (data: { browserPageId: string; token: string; active: boolean }) => void ) => () => void - agentInputFocusGuardReady: (token: string) => void + agentInputFocusGuardReady: (token: string, composing: boolean) => void onPaneFocus: ( callback: (data: { worktreeId: string | null; browserPageId: string }) => 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 db22536419ba..bbfd15d1b6d4 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -82,8 +82,8 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.on('browser:agentInputFocusGuard', listener) return () => ipcRenderer.removeListener('browser:agentInputFocusGuard', listener) }, - agentInputFocusGuardReady: (token: string): void => { - ipcRenderer.send('browser:agentInputFocusGuardReady', { token }) + agentInputFocusGuardReady: (token: string, composing: boolean): void => { + ipcRenderer.send('browser:agentInputFocusGuardReady', { token, composing }) }, onPaneFocus: ( callback: (data: { worktreeId: string | null; browserPageId: string }) => void diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 4ab623e0af36..9a6d153235e5 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -19,6 +19,10 @@ import type { } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../shared/file-write-disk-precondition' import type { CreateVenvResult, KernelFrameEvent, @@ -114,8 +118,9 @@ export type FilesystemApi = { content: string connectionId?: string access?: LocalFileAccess + diskPrecondition?: FileWriteDiskPrecondition } & SshMutationExpectation - ) => Promise + ) => Promise createFile: ( args: { filePath: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 0f560c0eb737..bb09579d8c2c 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -6,6 +6,10 @@ import type { } from '../../shared/native-file-drop-preparation' import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../shared/file-write-disk-precondition' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { AgentSessionAttachmentPathUploadResult, @@ -113,8 +117,9 @@ export const fsApi = { content: string connectionId?: string access?: LocalFileAccess + diskPrecondition?: FileWriteDiskPrecondition } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:writeFile', args), + ): Promise => ipcRenderer.invoke('fs:writeFile', args), createFile: ( args: { filePath: string; connectionId?: string } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:createFile', args), diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index b6189e3809a8..db106bcda5f2 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -31,7 +31,7 @@ import { } from '../components/terminal/terminal-provider-snapshot-capability' import { getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, + isOwnedSshTargetId, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId @@ -242,7 +242,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs. // Why: never dial runtime-owned (ephemeral-VM) targets from the renderer โ€” ssh.connect would dispose the runtime layer's live relay session; filter them out here too. const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( - (targetId) => !isRuntimeOwnedSshTargetId(targetId) + (targetId) => !isOwnedSshTargetId(targetId) ) if (connectionIds.length > 0) { try { diff --git a/src/renderer/src/components/FileTypeIcon.tsx b/src/renderer/src/components/FileTypeIcon.tsx new file mode 100644 index 000000000000..aecba8995ed7 --- /dev/null +++ b/src/renderer/src/components/FileTypeIcon.tsx @@ -0,0 +1,13 @@ +import { createElement, type CSSProperties } from 'react' +import { getFileTypeIcon } from '@/lib/file-type-icons' + +type FileTypeIconProps = { + filePath: string | null | undefined + className?: string + style?: CSSProperties +} + +/** The icon for a file path; one component so icon sources can change in one place. */ +export function FileTypeIcon({ filePath, className, style }: FileTypeIconProps): React.JSX.Element { + return createElement(getFileTypeIcon(filePath), { 'aria-hidden': true, className, style }) +} diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 4213866351c7..0d0c2f30cbed 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -2,7 +2,7 @@ import { useQuickOpenInteraction } from './use-quick-open-interaction' import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { CommandDialog, CommandInput, @@ -226,7 +226,6 @@ function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element ) : ( filtered.map((item) => { const { directory, filename } = splitTrailingSegment(item.path) - const FileIcon = getFileTypeIcon(item.path) return (
- + {/* shrink-0 + max-w-full: the directory gives up all of its width before the filename loses a character. */} diff --git a/src/renderer/src/components/automations/automation-host-catalog.ts b/src/renderer/src/components/automations/automation-host-catalog.ts index 71e445e546cf..94892971bb1b 100644 --- a/src/renderer/src/components/automations/automation-host-catalog.ts +++ b/src/renderer/src/components/automations/automation-host-catalog.ts @@ -4,7 +4,7 @@ import type { StableAutomationAuthorityRef, StableAutomationCatalogRef } from '../../../../shared/automation-owner-ref' -import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' +import { isOwnedSshTargetId } from '../../../../shared/execution-host' import { automationAuthorityCatalogKey, type AutomationCatalogSshMirrorInput, @@ -164,7 +164,7 @@ function resolveSshScopeGap( function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatalogEntry[] { const liveTargets = new Map( ctx.ssh.targets - .filter((target) => !isRuntimeOwnedSshTargetId(target.targetId)) + .filter((target) => !isOwnedSshTargetId(target.targetId)) .map((target) => [target.targetId, target]) ) const candidateIds = new Set(liveTargets.keys()) @@ -176,7 +176,7 @@ function projectSshEntries(ctx: AuthorityProjectionContext): AutomationHostCatal } const entries: AutomationHostCatalogEntry[] = [] for (const targetId of candidateIds) { - if (isRuntimeOwnedSshTargetId(targetId)) { + if (isOwnedSshTargetId(targetId)) { continue } const live = liveTargets.get(targetId) diff --git a/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts b/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts index caf51f558f45..d05e037596ed 100644 --- a/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { beginAgentInputFocusGuard, clearAgentInputFocusGuards, - endAgentInputFocusGuard + endAgentInputFocusGuard, + watchUserComposition } from './agent-input-focus-guard' import { webviewRegistry } from './webview-registry' @@ -24,12 +25,24 @@ function focusTerminal(): HTMLTextAreaElement { return terminal } +function compose( + element: HTMLElement, + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data = '' +): void { + element.dispatchEvent(new CompositionEvent(type, { bubbles: true, data })) +} + describe('agent input focus guard', () => { + let unwatchUserComposition: () => void + beforeEach(() => { vi.useFakeTimers() + unwatchUserComposition = watchUserComposition() }) afterEach(() => { + unwatchUserComposition() clearAgentInputFocusGuards() vi.runAllTimers() vi.useRealTimers() @@ -137,4 +150,61 @@ describe('agent input focus guard', () => { expect(document.activeElement).toBe(terminal) }) + + it('refuses a press while the user composes and leaves their focus alone', () => { + const webview = mountPage('page-1') + const terminal = focusTerminal() + compose(terminal, 'compositionstart') + + expect(beginAgentInputFocusGuard('page-1', 'input-1')).toBe('composing') + endAgentInputFocusGuard('input-1') + webview.focus() + vi.advanceTimersByTime(30_000) + + expect(document.activeElement).toBe(webview) + }) + + it('treats a nonempty update as an open composition and an empty one as not ending it', () => { + mountPage('page-1') + const terminal = focusTerminal() + + compose(terminal, 'compositionupdate', 'zhong') + expect(beginAgentInputFocusGuard('page-1', 'input-1')).toBe('composing') + compose(terminal, 'compositionupdate', '') + expect(beginAgentInputFocusGuard('page-1', 'input-2')).toBe('composing') + compose(terminal, 'compositionend') + expect(beginAgentInputFocusGuard('page-1', 'input-3')).toBe('ready') + }) + + it('keeps a Cangjie composition open through its own input and Backspace', () => { + mountPage('page-1') + const terminal = focusTerminal() + const composingInput = (inputType?: string): InputEvent => + new InputEvent('input', { bubbles: true, isComposing: true, inputType }) + + // Recorded: compose ๅฐธ and ๅฃ, then Backspace leaves ๅฐธ unfinished. + compose(terminal, 'compositionstart') + compose(terminal, 'compositionupdate', 'ๅฐธ') + terminal.dispatchEvent(composingInput()) + compose(terminal, 'compositionupdate', 'ๅฐธๅฃ') + terminal.dispatchEvent(composingInput()) + compose(terminal, 'compositionupdate', 'ๅฐธ') + terminal.dispatchEvent(composingInput('deleteContentBackward')) + + expect(beginAgentInputFocusGuard('page-1', 'input-1')).toBe('composing') + }) + + it('guards a press once the composition ended by typing or the user left it', () => { + mountPage('page-1') + const terminal = focusTerminal() + + compose(terminal, 'compositionstart') + terminal.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) + expect(beginAgentInputFocusGuard('page-1', 'input-1')).toBe('ready') + endAgentInputFocusGuard('input-1') + + compose(terminal, 'compositionstart') + terminal.blur() + expect(beginAgentInputFocusGuard('page-1', 'input-2')).toBe('ready') + }) }) diff --git a/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.ts b/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.ts index da8216d02124..02794fcf3a73 100644 --- a/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.ts +++ b/src/renderer/src/components/browser-pane/host-guest/agent-input-focus-guard.ts @@ -11,6 +11,9 @@ const LATE_FOCUS_RECHECK_MS = 50 const ABANDONED_GUARD_MS = 30_000 const releasesByToken = new Map void>() +// Why: blurring an element mid-composition commits the unfinished text, which a terminal sends to +// the shell, so agent presses are refused while the user composes (#28700). +let composingElement: EventTarget | null = null // Why: while any agent input or its recheck is in flight, document.activeElement may be a page // an agent focused, not the user's choice; overlapping guards share the element seen first. let inFlight = 0 @@ -23,7 +26,65 @@ function settle(): void { } } -export function beginAgentInputFocusGuard(browserPageId: string, token: string): void { +function isUserComposing(): boolean { + return composingElement !== null && composingElement === document.activeElement +} + +/** Tracks the window's open IME composition, by the terminal tracker's rules; returns the unsubscribe. */ +export function watchUserComposition(): () => void { + if (typeof document === 'undefined') { + return () => {} + } + const onStart = (event: Event): void => { + composingElement = event.target + } + // Why: some IMEs send a nonempty update without a new compositionstart; Sogou/fcitx send empty + // ones while the candidate popup is still open, so those must not end it. + const onUpdate = (event: Event): void => { + if (event instanceof CompositionEvent && event.data !== '') { + composingElement = event.target + } + } + const onEnd = (): void => { + composingElement = null + } + // Why: typing outside a composition proves none is open, so a missed compositionend cannot stick. + // Composition-owned input, like Cangjie's Backspace over one of several radicals, keeps it open. + const onInput = (event: Event): void => { + if (!(event instanceof InputEvent && event.isComposing)) { + composingElement = null + } + } + const listeners: [string, (event: Event) => void][] = [ + ['compositionstart', onStart], + ['compositionupdate', onUpdate], + ['compositionend', onEnd], + ['input', onInput] + ] + for (const [type, listener] of listeners) { + document.addEventListener(type, listener, true) + } + return () => { + for (const [type, listener] of listeners) { + document.removeEventListener(type, listener, true) + } + composingElement = null + } +} + +/** Refuses the input while the user composes; otherwise holds their focus until the input ends. */ +export function beginAgentInputFocusGuard( + browserPageId: string, + token: string +): 'ready' | 'composing' { + if (isUserComposing()) { + return 'composing' + } + guardUserFocus(browserPageId, token) + return 'ready' +} + +function guardUserFocus(browserPageId: string, token: string): void { const webview = webviewRegistry.get(browserPageId) if (!webview) { return diff --git a/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx index eb79eed7d6a0..d8163f21a61a 100644 --- a/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx +++ b/src/renderer/src/components/browser-pane/navigate/browser-load-failure-overlay.tsx @@ -1,3 +1,4 @@ +import { SecuritySurface } from '@/components/ui/security-surface' import { useEffect, useRef, useState } from 'react' import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react' @@ -261,107 +262,114 @@ export function BrowserLoadFailureOverlay({ ) : null return ( -
-
-
- {certificateError ? : } -
-

- {certificateError - ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") - : guestRecoveryError - ? translate('browser.guestRecovery.title', 'Browser page stopped') - : meta.host - ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { - value0: meta.host - }) - : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} -

-

- {formatLoadFailureDescription(presentationLoadError, meta)} -

- {certificateError && meta.isLocalhostLike ? ( -

- {translate( - 'browser.loadFailure.trustedCertificateGuidance', - 'For local development, use a trusted local certificate when possible.' - )} + +

+
+
+ {certificateError ? : } +
+

+ {certificateError + ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") + : guestRecoveryError + ? translate('browser.guestRecovery.title', 'Browser page stopped') + : meta.host + ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { + value0: meta.host + }) + : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} +

+

+ {formatLoadFailureDescription(presentationLoadError, meta)}

- ) : null} - {recoveryHint ?

{recoveryHint}

: null} - {sshRoutedHint ? ( -

- {translate( - 'browser.loadFailure.sshRoutedHint', - "This page browses through the workspace's SSH host โ€” the host may be disconnected or unable to reach this site." - )} - {onRecheckSshRoute ? ( + {certificateError && meta.isLocalhostLike ? ( +

+ {translate( + 'browser.loadFailure.trustedCertificateGuidance', + 'For local development, use a trusted local certificate when possible.' + )} +

+ ) : null} + {recoveryHint ? ( +

{recoveryHint}

+ ) : null} + {sshRoutedHint ? ( +

+ {translate( + 'browser.loadFailure.sshRoutedHint', + "This page browses through the workspace's SSH host โ€” the host may be disconnected or unable to reach this site." + )} + {onRecheckSshRoute ? ( + <> + {' '} + + + ) : null} +

+ ) : null} + {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( +

+ {formatCertificateProceedFailure(activeProceedAttempt.reason)} +

+ ) : null} +
+ {certificateError ? ( <> - {' '} - + {externalButton} + {retryButton} + {copyButton} + {matchingCertificateFailure ? ( + + ) : null} - ) : null} -

- ) : null} - {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( -

- {formatCertificateProceedFailure(activeProceedAttempt.reason)} -

- ) : null} -
- {certificateError ? ( - <> - {externalButton} - {retryButton} - {copyButton} - {matchingCertificateFailure ? ( - - ) : null} - - ) : ( - <> - {httpsRecoveryUrl && !guestRecoveryError ? ( - - ) : null} - {retryButton} - {copyButton} - {externalButton} - - )} + ) : ( + <> + {httpsRecoveryUrl && !guestRecoveryError ? ( + + ) : null} + {retryButton} + {copyButton} + {externalButton} + + )} +
-
+ ) } diff --git a/src/renderer/src/components/editor/ConflictReviewFileTree.tsx b/src/renderer/src/components/editor/ConflictReviewFileTree.tsx index 34031daf8296..d3cfcd7f5519 100644 --- a/src/renderer/src/components/editor/ConflictReviewFileTree.tsx +++ b/src/renderer/src/components/editor/ConflictReviewFileTree.tsx @@ -1,7 +1,7 @@ import React from 'react' import { ChevronDown, Folder, FolderOpen, PanelLeftClose } from 'lucide-react' import { Button } from '@/components/ui/button' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { cn } from '@/lib/utils' import { buildSourceControlTree, @@ -164,8 +164,6 @@ function ConflictReviewFileTreeRow({ ) } - - const FileIcon = getFileTypeIcon(node.entry.path) const liveEntry = node.entry.liveEntry const isStillUnresolved = liveEntry?.conflictStatus === 'unresolved' @@ -187,7 +185,8 @@ function ConflictReviewFileTreeRow({ } }} > - {React.createElement(FileIcon, { + {React.createElement(FileTypeIcon, { + filePath: node.entry.path, className: cn('size-3.5 shrink-0', isStillUnresolved && 'text-destructive') })} diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index 03beb68b122f..e5283b7cb85f 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -255,7 +255,7 @@ export default function IpynbViewer({ } }} > - + {translate('auto.components.editor.IpynbViewer.9e06ae5d36', 'Run Notebook Code?')} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index fe6d5644db1c..0f50d48a1daa 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -2,7 +2,7 @@ import { createElement, memo } from 'react' import { ChevronDown, Folder, FolderOpen } from 'lucide-react' import { STATUS_COLORS, STATUS_LABELS } from '@/components/right-sidebar/status-display' import type { SourceControlTreeNode } from '@/components/right-sidebar/source-control-tree' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' @@ -95,7 +95,6 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ } const sectionKey = getCombinedDiffFileTreeSectionKey(mode, node.entry) - const FileIcon = getFileTypeIcon(node.entry.path) const fileName = basename(node.entry.path) const parentDir = dirname(node.entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -130,7 +129,8 @@ export const CombinedDiffFileTreeRow = memo(function CombinedDiffFileTreeRow({ }} onClick={() => onNavigate(node.entry)} > - {createElement(FileIcon, { + {createElement(FileTypeIcon, { + filePath: node.entry.path, className: 'size-3.5 shrink-0', style: { color: STATUS_COLORS[status] } })} diff --git a/src/renderer/src/components/editor/diff-content-signature.ts b/src/renderer/src/components/editor/diff-content-signature.ts index ba5bc26fc4bd..9c74a2078181 100644 --- a/src/renderer/src/components/editor/diff-content-signature.ts +++ b/src/renderer/src/components/editor/diff-content-signature.ts @@ -10,18 +10,5 @@ export function getDiffContentSignature(content: string): string { return (hash >>> 0).toString(16) } -// Why: the disk baseline guards against silently overwriting external writes -// (issue #7265), where a hash collision means a missed conflict โ€” so it gets -// two independent FNV lanes plus the length instead of the single 32-bit lane -// that suffices for cosmetic model rotation above. -export function getDiskBaselineSignature(content: string): string { - let hashA = 2166136261 - let hashB = 84696351 - for (let i = 0; i < content.length; i += 1) { - const code = content.charCodeAt(i) - hashA ^= code - hashA = Math.imul(hashA, 16777619) - hashB = Math.imul(hashB ^ code, 1099511627) - } - return `${(hashA >>> 0).toString(16)}-${(hashB >>> 0).toString(16)}-${content.length.toString(16)}` -} +// Why shared: the writer that refuses a stale save hashes disk with this same function. +export { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 2918058b7908..7dd0c988b09d 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -1,3 +1,4 @@ +import { SecuritySurface } from '@/components/ui/security-surface' import { useRef } from 'react' import { ShieldQuestion, X } from 'lucide-react' import { cn } from '@/lib/utils' @@ -60,133 +61,136 @@ export function NativeChatApprovalCard({ const escape = onCancel ?? (isSubmitting ? undefined : onCollapse) return ( -
-
-
{ - if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) { - event.preventDefault() - event.stopPropagation() - escape() - } - }} - className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - > -
- -
-

- {approval.title} -

-
- {onCancel ? ( - - ) : onCollapse ? ( - - ) : null} -
- {hasContext ? ( -
- {approval.description ? ( -

{approval.description}

- ) : null} - {approval.decisionReason ? ( -

- - {translate('components.native-chat.approval.reason', 'Reason')}:{' '} - - {approval.decisionReason} -

- ) : null} - {neededPath ? ( -

- - {translate('components.native-chat.approval.needsAccess', 'Needs access to')} - :{' '} - - {neededPath} + +

+
+
{ + if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) { + event.preventDefault() + event.stopPropagation() + escape() + } + }} + className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > +
+ +
+

+ {approval.title}

- ) : null} - {isPlanApprovalSubject(approval.subject) ? ( -
- - {approval.subject.filePath ? ( -

- - {translate('components.native-chat.approval.plan.file', 'Plan file')}:{' '} - - {approval.subject.filePath} -

- ) : null} -
- ) : approval.detail ? ( -
+ {onCancel ? ( +
- ) : null} - {newerSubject ? ( -

- {translate( - 'components.native-chat.approval.needsNewerOrca', - 'This request needs a newer version of Orca.' - )} -

+ + + ) : onCollapse ? ( + ) : null}
- ) : null} -
- {approval.options.map((opt, i) => ( - - ))} + {approval.description ? ( +

{approval.description}

+ ) : null} + {approval.decisionReason ? ( +

+ + {translate('components.native-chat.approval.reason', 'Reason')}:{' '} + + {approval.decisionReason} +

+ ) : null} + {neededPath ? ( +

+ + {translate('components.native-chat.approval.needsAccess', 'Needs access to')} + :{' '} + + {neededPath} +

+ ) : null} + {isPlanApprovalSubject(approval.subject) ? ( +
+ + {approval.subject.filePath ? ( +

+ + {translate('components.native-chat.approval.plan.file', 'Plan file')} + :{' '} + + {approval.subject.filePath} +

+ ) : null} +
+ ) : approval.detail ? ( +
+ {approval.detail} +
+ ) : null} + {newerSubject ? ( +

+ {translate( + 'components.native-chat.approval.needsNewerOrca', + 'This request needs a newer version of Orca.' + )} +

+ ) : null} +
+ ) : null} +
+ {approval.options.map((opt, i) => ( + + ))} +
-
+ ) } diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index a0d6ef43737d..632bc3a151b9 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { SkillSourceKind } from '../../../../shared/skills' import { FilenameFirstPath } from '@/components/file-path-cursor-tooltip' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' import type { NativeChatMentionFiles } from './use-native-chat-mention-files' import { LoadingSpinner } from '@/components/LoadingSpinner' @@ -340,7 +340,6 @@ export const NativeChatMentionMenu = memo(function NativeChatMentionMenu({ return ( {files.map((path, index) => { - const FileIcon = getFileTypeIcon(path) return ( onChoose(path)} > - + ) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 30c1bf6ff9eb..59b3f193e472 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -14,6 +14,7 @@ import type { NativeChatComposerFieldProps, NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' type FieldProps = { imageAttachments?: readonly NativeChatComposerImageAttachment[] @@ -51,9 +52,12 @@ vi.mock('../../store', () => { return { useAppStore } }) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn(), emitNativeChatPickerItemAccepted: vi.fn(), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index d4d852f5919a..3fb745919cb5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -8,6 +8,7 @@ import type { } from '../../../../shared/native-chat-session-options' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), @@ -69,9 +70,12 @@ vi.mock('../../store', () => { }) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatTypedCommand: (...args: unknown[]) => mocks.sendNativeChatTypedCommand(...args), diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 06113cae6b16..7b135e0fce19 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows' +import { hasSleepableWorkspaceActivity } from '../sidebar/workspace-lineage-menu-actions' import { resolveAttention } from '../../../../shared/sidebar-list/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -68,6 +69,7 @@ import { useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { useAppStore } from '@/store' const structuredTab = { id: 'structured-tab-1', @@ -649,6 +651,33 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) + it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { + const state = (): AppState => useAppStore.getState() + const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) + const paneKey = statuses()[0].paneKey + mocks.store?.setState({ acknowledgedAgentsByPaneKey: { [paneKey]: 3 } }) + expect(sleepable()).toBe(true) + + // Sleep's terminal teardown drops the worktree's rows before the host's revoke reaches the feed. + act(() => state().dropAgentStatusByWorktree('wt-1', { shutdownReason: 'manual-sleep' })) + expect(state().agentStatusByPaneKey[paneKey]).toEqual( + expect.objectContaining({ state: 'done', structuredHostOwned: true }) + ) + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', updatedAt: 2, hostExecutionOwned: undefined }) + }) + ) + expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) + expect(statuses()[0].structuredHostOwned).toBeUndefined() + expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) + expect(sleepable()).toBe(false) + }) + it('drops the status and the feed when the last structured tab closes', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index 4784348dc7d5..53feaa978f65 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -1,5 +1,6 @@ import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' -import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' import { NATIVE_CHAT_SUBMIT } from './native-chat-send' import { stripScrollbackAnsi } from './native-chat-scrape-fallback' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 82370f606b09..9ef51f4ab499 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -9,9 +9,13 @@ import type * as SendHook from './use-native-chat-structured-composer-send' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import { createMemoryNativeChatComposerDraftStorage } from './native-chat-composer-draft-storage' import { imageReferenceToken } from '../../../../shared/native-chat-image-reference' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) vi.mock('@/lib/worker-terminal-takeover-report', () => ({ reportStructuredSessionUserInput: vi.fn() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index b682c8066b68..10e1d6bf9841 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -17,6 +17,7 @@ import { readNativeChatAttachmentCache, useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const electron = vi.hoisted(() => ({ on: vi.fn(), @@ -51,7 +52,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) import { installOsFileDropCancellationGuard } from '@/lib/os-file-drop-cancellation-guard' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx index dd559941eb9f..efac7e2b98ab 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-ime-file-drop.test.tsx @@ -15,6 +15,7 @@ import { useNativeChatDraft } from './use-native-chat-draft' import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const testState: { executionHostId: ExecutionHostId @@ -51,7 +52,8 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), isRemoteRuntimePtyId: () => testState.targetIsRemoteRuntime })) vi.mock('./NativeChatComposerActions', () => ({ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts index 31921ac11d81..da865a0ddf3c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) import { nativeChatComposerPlaceholder } from './native-chat-composer-target' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx index 8e20ac067a95..56b615051130 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx @@ -5,6 +5,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback @@ -30,9 +31,12 @@ vi.mock('../../store', () => { return { useAppStore } }) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: vi.fn(), sendNativeChatTypedCommand: vi.fn(), diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index 548fed952a62..a71dba5f13ba 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -1,6 +1,6 @@ import { translate } from '@/i18n/i18n' import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' export type NativeChatResolvedTarget = { ptyId: string diff --git a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx index 1686b08fedfb..3587d870fa7a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx @@ -16,6 +16,7 @@ import { NativeChatComposerField } from './NativeChatComposerField' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatWorkspaceFileDrop } from './use-native-chat-workspace-file-drop' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const testState: { executionHostId: ExecutionHostId @@ -52,7 +53,8 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), isRemoteRuntimePtyId: () => testState.targetIsRemoteRuntime })) vi.mock('./NativeChatComposerActions', () => ({ diff --git a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx index 83d161371998..a3bf8fce2147 100644 --- a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx @@ -13,6 +13,7 @@ import { clearNativeChatAttachmentCacheForTests, useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const mocks = vi.hoisted(() => { const state: { @@ -36,7 +37,8 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ tabsByWorktree: mocks.tabsByWorktree }) } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), isRemoteRuntimePtyId: () => mocks.remoteTarget })) vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts index 765639184968..92c6eda5c4ea 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' const io = vi.hoisted(() => ({ write: vi.fn(), verified: vi.fn() })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: io.write, + sendRuntimePtyInput: io.write +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: io.verified })) import { diff --git a/src/renderer/src/components/native-chat/native-chat-observed-send.ts b/src/renderer/src/components/native-chat/native-chat-observed-send.ts index 04692fdb682f..1b96acd49cec 100644 --- a/src/renderer/src/components/native-chat/native-chat-observed-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -1,4 +1,4 @@ -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue' import { clearConfirmDurationMs, diff --git a/src/renderer/src/components/native-chat/native-chat-remote-image-paste.test.tsx b/src/renderer/src/components/native-chat/native-chat-remote-image-paste.test.tsx index 9638f3be2c52..27a5fbf5f204 100644 --- a/src/renderer/src/components/native-chat/native-chat-remote-image-paste.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-remote-image-paste.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, createElement, useEffect, useRef, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' const mocks = vi.hoisted(() => ({ saveClipboardImageAsTempFile: vi.fn(), @@ -17,7 +18,10 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => true })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => true +})) vi.stubGlobal('window', { ...window, diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index f520d6985a4e..c373b2d69c48 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -6,7 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendRuntimePtyInput = vi.fn() const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 34f6872936ab..6bddf8b4e888 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendRuntimePtyInput = vi.fn() const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index f210c30b35d5..2e4696cf2e95 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -3,10 +3,8 @@ // byte builders in native-chat-send.ts so those stay IO-free and unit-testable. import { sendNativeChatObservedWrites } from './native-chat-observed-send' -import { - sendRuntimePtyInput, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { clearConfirmDurationMs, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 67240d3763cc..ca857e9881f6 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -129,6 +129,40 @@ describe('native chat session option enrichment', () => { expect(models.at(-1)).toMatchObject({ id: 'account-model' }) expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull() }) + it('keeps OMP terminal model discovery free of launch-only thinking options', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { + id: 'custom/model', + label: 'Model', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' } + ] + }, + { id: 'custom/plain', label: 'Plain' } + ] + }) + const discovered = await discoverNativeChatCatalogModels('omp', { + target: { kind: 'local' as const }, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + ensureNativeChatModelEnrichment({ + agent: 'omp', + hostKey: 'local', + discover: async () => discovered + }) + await vi.waitFor(() => expect(readNativeChatEnrichedModels('omp', 'local')).not.toBeNull()) + expect(readNativeChatEnrichedModels('omp', 'local')).toEqual(discovered) + expect(readNativeChatEnrichedModels('omp', 'local')?.map(({ options }) => options)).toEqual([ + [], + [] + ]) + expect(readNativeChatEnrichedModels('omp', 'ssh:other')).toBeNull() + }) it('falls back permanently to the seed after a failed once-per-host probe', async () => { const discover = vi.fn().mockRejectedValue(new Error('offline')) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx index 88369c5157e5..d8e254c52adf 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-structured-send-composition-clear.test.tsx @@ -5,6 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback @@ -35,9 +36,12 @@ vi.mock('../../store', () => { return { useAppStore } }) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: () => false, sendRuntimePtyInput: vi.fn() })) +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: vi.fn(), sendNativeChatTypedCommand: vi.fn(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index f3247aa797d3..29d8384a8461 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -14,12 +14,14 @@ import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attac import { readNativeChatDraftCache } from './native-chat-draft-cache' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' import { imageReferenceToken } from '../../../../shared/native-chat-image-reference' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) const runtimeTarget = vi.hoisted(() => ({ remote: false })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), isRemoteRuntimePtyId: () => runtimeTarget.remote })) diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 498f6ff0392b..6266eeea5002 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -33,8 +33,7 @@ vi.mock('../../store', () => ({ })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args), - sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args) + sendRuntimePtyInput: (...args: unknown[]) => mocks.sendRuntimePtyInput(...args) })) vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: (...args: unknown[]) => mocks.sendRuntimePtyInputVerified(...args) diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx index e6cc92baebe9..fcab5b2c5a56 100644 --- a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -11,13 +11,6 @@ import { import type { ExecutionHostId } from '../../../../shared/execution-host' import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' -import { - ConnectHostButton, - HostRowIcon, - NeedsSetupHostIcon, - RunTargetRow, - SetLocationButton -} from './RunTargetComboboxRow' import { buildRunTargetRows, getEphemeralVmLabel, @@ -25,7 +18,8 @@ import { RUN_TARGET_ADD_HOST_KEY, type EphemeralVmRecipeOption } from './run-target-options' -import { AddHostSubmenuRow, RecipesSubmenuRow } from './RunTargetSubmenus' +import { AddHostSubmenuRow } from './RunTargetSubmenus' +import RunTargetRowList from './RunTargetRowList' import RunTargetField from './RunTargetField' type RunTargetComboboxProps = { @@ -292,91 +286,27 @@ export default function RunTargetCombobox({ )}

) : null} - {rows.map((row) => { - if (row.kind === 'add-host') { - return null - } - const isArmed = armedRow?.key === row.key - const optionId = isArmed ? `${listId}-armed` : undefined - if (row.kind === 'ready') { - return ( - } - label={row.option.label} - detail={row.option.path} - armed={isArmed} - current={selectedRecipe === null && row.option === selectedHost} - optionId={optionId} - onArm={() => { - arm(row.key) - setSubmenu(null) - }} - onCommit={() => selectHost(row.option)} - /> - ) - } - if (row.kind === 'needs-setup') { - const connecting = connectingHostIds.has(row.option.hostId) - const hasConnect = Boolean(row.option.connectAction && onConnectHost) - const hasSetLocation = Boolean(row.option.canSetLocation && onSetLocation) - return ( - - } - label={row.option.label} - // Why: Connect / Set project location already say the next - // step, so the detail line would only repeat that. - detail={hasConnect || hasSetLocation ? '' : row.option.detail} - armed={isArmed} - current={false} - dimmed - optionId={optionId} - onArm={() => { - arm(row.key) - setSubmenu(null) - }} - onCommit={() => setLocation(row.option)} - trailing={ - hasConnect ? ( - void connectHost(row.option)} - /> - ) : hasSetLocation ? ( - setLocation(row.option)} - /> - ) : undefined - } - /> - ) - } - // Recipes submenu row. - return ( - setSubmenu(next ? 'recipes' : null)} - armed={isArmed} - optionId={optionId} - recipes={matchedRecipes} - selectedRecipeId={selectedRecipe?.id ?? null} - onArm={() => { - arm(row.key) - setSubmenu('recipes') - }} - onSelectRecipe={selectRecipe} - /> - ) - })} + { + arm(key) + setSubmenu(nextSubmenu) + }} + onRecipesOpenChange={(next) => setSubmenu(next ? 'recipes' : null)} + onSelectHost={selectHost} + onSelectRecipe={selectRecipe} + onConnectHost={(option) => void connectHost(option)} + onSetLocation={setLocation} + />
{hasAddHost ? ( ) } - -/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */ -export function NeedsSetupHostIcon({ - hostId, - connecting, - attention -}: { - hostId: ExecutionHostId - connecting: boolean - attention: boolean -}): React.JSX.Element { - if (connecting) { - return - } - if (attention) { - return - } - return -} - -/** Inline Connect action on a disconnected host row. */ -export function ConnectHostButton({ - connecting, - onConnect -}: { - connecting: boolean - onConnect: () => void -}): React.JSX.Element { - return ( - - ) -} - -/** Inline Set project location action on a host that still needs a project path. */ -export function SetLocationButton({ - hostLabel, - onSetLocation -}: { - hostLabel: string - onSetLocation: () => void -}): React.JSX.Element { - const label = translate( - 'auto.components.NewWorkspaceComposerCard.setLocation', - 'Set project location' - ) - return ( - - - - - - {translate( - 'auto.components.NewWorkspaceComposerCard.setLocationTooltip', - 'Choose a folder or clone this project onto {{host}}.', - { host: hostLabel } - )} - - - ) -} diff --git a/src/renderer/src/components/new-workspace/RunTargetRowActions.tsx b/src/renderer/src/components/new-workspace/RunTargetRowActions.tsx new file mode 100644 index 000000000000..8e2f630d1292 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetRowActions.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { AlertTriangle, LoaderCircle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { translate } from '@/i18n/i18n' +import { HostRowIcon } from '../host-row-icon' + +/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */ +export function NeedsSetupHostIcon({ + hostId, + connecting, + attention +}: { + hostId: ExecutionHostId + connecting: boolean + attention: boolean +}): React.JSX.Element { + if (connecting) { + return + } + if (attention) { + return + } + return +} + +/** Inline Connect action on a disconnected host row. */ +export function ConnectHostButton({ + connecting, + onConnect +}: { + connecting: boolean + onConnect: () => void +}): React.JSX.Element { + return ( + + ) +} + +/** Inline Set project location action on a host that still needs a project path. */ +export function SetLocationButton({ + hostLabel, + onSetLocation +}: { + hostLabel: string + onSetLocation: () => void +}): React.JSX.Element { + const label = translate( + 'auto.components.NewWorkspaceComposerCard.setLocation', + 'Set project location' + ) + return ( + + + + + + {translate( + 'auto.components.NewWorkspaceComposerCard.setLocationTooltip', + 'Choose a folder or clone this project onto {{host}}.', + { host: hostLabel } + )} + + + ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetRowList.tsx b/src/renderer/src/components/new-workspace/RunTargetRowList.tsx new file mode 100644 index 000000000000..ed5a641c2708 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetRowList.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import type { + NeedsSetupProjectHostOption, + ReadyProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { HostRowIcon, RunTargetRow } from './RunTargetComboboxRow' +import { ConnectHostButton, NeedsSetupHostIcon, SetLocationButton } from './RunTargetRowActions' +import { RecipesSubmenuRow } from './RunTargetSubmenus' +import type { EphemeralVmRecipeOption, RunTargetRowModel } from './run-target-options' + +/** The scrolling rows of the run-target picker; "Add host" is pinned outside this list. */ +export default function RunTargetRowList({ + rows, + armedKey, + listId, + selectedHost, + selectedRecipeId, + matchedRecipes, + recipesOpen, + connectingHostIds, + canConnect, + canSetLocation, + onArm, + onRecipesOpenChange, + onSelectHost, + onSelectRecipe, + onConnectHost, + onSetLocation +}: { + rows: readonly RunTargetRowModel[] + armedKey: string | null + listId: string + /** The committed host, or null while a recipe holds the selection. */ + selectedHost: ReadyProjectHostSetupOption | null + selectedRecipeId: string | null + matchedRecipes: readonly EphemeralVmRecipeOption[] + recipesOpen: boolean + connectingHostIds: ReadonlySet + canConnect: boolean + canSetLocation: boolean + /** Arms a row and opens the submenu that row owns, if any. */ + onArm: (key: string, submenu: 'recipes' | null) => void + onRecipesOpenChange: (open: boolean) => void + onSelectHost: (option: ReadyProjectHostSetupOption) => void + onSelectRecipe: (recipeId: string) => void + onConnectHost: (option: NeedsSetupProjectHostOption) => void + onSetLocation: (option: NeedsSetupProjectHostOption) => void +}): React.JSX.Element { + return ( + <> + {rows.map((row) => { + if (row.kind === 'add-host') { + return null + } + const isArmed = armedKey === row.key + const optionId = isArmed ? `${listId}-armed` : undefined + if (row.kind === 'ready') { + return ( + } + label={row.option.label} + detail={row.option.path} + armed={isArmed} + current={row.option === selectedHost} + optionId={optionId} + onArm={() => onArm(row.key, null)} + onCommit={() => onSelectHost(row.option)} + /> + ) + } + if (row.kind === 'needs-setup') { + const connecting = connectingHostIds.has(row.option.hostId) + const hasConnect = Boolean(row.option.connectAction && canConnect) + const hasSetLocation = Boolean(row.option.canSetLocation && canSetLocation) + return ( + + } + label={row.option.label} + // Why: Connect / Set project location already say the next + // step, so the detail line would only repeat that. + detail={hasConnect || hasSetLocation ? '' : row.option.detail} + armed={isArmed} + current={false} + dimmed + optionId={optionId} + onArm={() => onArm(row.key, null)} + onCommit={() => onSetLocation(row.option)} + trailing={ + hasConnect ? ( + onConnectHost(row.option)} + /> + ) : hasSetLocation ? ( + onSetLocation(row.option)} + /> + ) : undefined + } + /> + ) + } + // Recipes submenu row. + return ( + onArm(row.key, 'recipes')} + onSelectRecipe={onSelectRecipe} + /> + ) + })} + + ) +} diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts index 394d3cd2cdc4..32de2fd18494 100644 --- a/src/renderer/src/components/new-workspace/run-target-options.ts +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -54,28 +54,16 @@ function matches(haystack: string, query: string): boolean { return haystack.toLowerCase().includes(query) } -/** - * Filters hosts and recipes by a typed query. The submenu rows survive filtering - * only while they still have something to show โ€” "Add host" always survives, so - * it stays reachable in every state including no-matches. - */ -export function buildRunTargetRows({ - hostOptions, - recipes, - query, - hasAddHost -}: { - hostOptions: readonly ProjectHostSetupOption[] - recipes: readonly EphemeralVmRecipeOption[] +/** Ready hosts first, then hosts that still need a connection or a project location. */ +function buildHostRows( + hostOptions: readonly ProjectHostSetupOption[], query: string - hasAddHost: boolean -}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } { - const trimmed = query.trim().toLowerCase() +): RunTargetRowModel[] { const hostMatches = (option: ProjectHostSetupOption): boolean => - trimmed === '' || - matches(option.label, trimmed) || - matches(option.detail, trimmed) || - (option.kind === 'ready' && matches(option.path, trimmed)) + query === '' || + matches(option.label, query) || + matches(option.detail, query) || + (option.kind === 'ready' && matches(option.path, query)) const ready = hostOptions.filter( (option): option is ReadyProjectHostSetupOption => @@ -85,15 +73,7 @@ export function buildRunTargetRows({ (option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup' && hostMatches(option) ) - const matchedRecipes = recipes.filter( - (recipe) => - trimmed === '' || - matches(recipe.name, trimmed) || - matches(getEphemeralVmLabel(), trimmed) || - matches(recipe.description ?? '', trimmed) - ) - - const rows: RunTargetRowModel[] = [ + return [ ...ready.map((option) => ({ key: `host:${getProjectHostSetupSelectionKey(option)}`, kind: 'ready' as const, @@ -105,11 +85,54 @@ export function buildRunTargetRows({ option })) ] - if (matchedRecipes.length > 0) { +} + +function matchRecipes( + recipes: readonly EphemeralVmRecipeOption[], + query: string +): EphemeralVmRecipeOption[] { + return recipes.filter( + (recipe) => + query === '' || + matches(recipe.name, query) || + matches(getEphemeralVmLabel(), query) || + matches(recipe.description ?? '', query) + ) +} + +/** The rows that open a nested list rather than committing. */ +function buildSubmenuRows(hasRecipes: boolean, hasAddHost: boolean): RunTargetRowModel[] { + const rows: RunTargetRowModel[] = [] + if (hasRecipes) { rows.push({ key: RUN_TARGET_RECIPES_KEY, kind: 'recipes' }) } if (hasAddHost) { rows.push({ key: RUN_TARGET_ADD_HOST_KEY, kind: 'add-host' }) } + return rows +} + +/** + * Filters hosts and recipes by a typed query. The submenu rows survive filtering + * only while they still have something to show โ€” "Add host" always survives, so + * it stays reachable in every state including no-matches. + */ +export function buildRunTargetRows({ + hostOptions, + recipes, + query, + hasAddHost +}: { + hostOptions: readonly ProjectHostSetupOption[] + recipes: readonly EphemeralVmRecipeOption[] + query: string + hasAddHost: boolean +}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } { + const trimmed = query.trim().toLowerCase() + const matchedRecipes = matchRecipes(recipes, trimmed) + const rows: RunTargetRowModel[] = [ + ...buildHostRows(hostOptions, trimmed), + ...buildSubmenuRows(matchedRecipes.length > 0, hasAddHost) + ] return { rows, matchedRecipes } } diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index f1e7606524ea..8267878faffd 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react' import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { encodeWorkspaceFilePaths, WORKSPACE_FILE_PATH_MIME, @@ -125,7 +125,6 @@ export function FileExplorerRow({ onNativeDragTargetChange, onNativeDragExpandDir }: FileExplorerRowProps): React.JSX.Element { - const FileIcon = getFileTypeIcon(node.relativePath || node.name) const showLoadError = node.isDirectory && isExpanded && !isLoading && loadError !== null const rowDropDir = node.isDirectory ? node.path : targetDir const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = @@ -230,7 +229,8 @@ export function FileExplorerRow({ {node.isSymlink ? ( ) : ( - React.createElement(FileIcon, { + React.createElement(FileTypeIcon, { + filePath: node.relativePath || node.name, className: 'size-3 shrink-0 text-muted-foreground' }) )} diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx index 9b95b008e874..ae1930643f50 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { ChevronRight, Copy } from 'lucide-react' import { basename, dirname } from '@/lib/path' import { cn } from '@/lib/utils' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { @@ -64,7 +64,6 @@ export function FileResultRow({ const fileName = basename(fileResult.relativePath) const parentDir = dirname(fileResult.relativePath) const dirPath = parentDir === '.' ? '' : parentDir - const FileIcon = getFileTypeIcon(fileResult.relativePath) const matchCount = normalizeSearchFileMatchCount(fileResult) return ( @@ -87,7 +86,8 @@ export function FileResultRow({ !collapsed && 'rotate-90' )} /> - {React.createElement(FileIcon, { + {React.createElement(FileTypeIcon, { + filePath: fileResult.relativePath, className: 'size-3.5 flex-shrink-0 text-muted-foreground' })}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts index 104cb4f4342b..62a14290d9e3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts @@ -52,6 +52,7 @@ export function cacheAiVaultSessionList( key: aiVaultSessionResultCacheKey(request.executionHostScope, request.scopePaths), executionHostScope: request.executionHostScope, limit: request.sessionLimit, + scopePaths: request.scopePaths, result, replaceHostEntries: options.replaceHostEntries }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 03d2531e4b77..12d7819b3a94 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -262,6 +262,19 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ limit: 1000, force: false }) }) + // A deeper scan keeps every scoped row, so reusing it would ignore the lowered depth. + it('rescans a scoped view at a lowered depth instead of reusing the deeper scan', async () => { + listSessionsMock.mockResolvedValue({ ...EMPTY_RESULT, sessions: [makeVaultSession(1)] }) + await renderHook(['/repo'], 'ssh:dev-box', 'unlimited') + await flushMicrotasks() + + await rerenderHook(['/repo'], 'ssh:dev-box', 250) + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ limit: 250, scopePaths: ['/repo'] }) + }) + it('reuses a loaded larger depth when lowering and raising within its coverage', async () => { const loaded = { ...EMPTY_RESULT, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts index f86f8c45b941..81ebb7289fc0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-result-cache.ts @@ -1,7 +1,7 @@ import type { AiVaultListResult } from '../../../../shared/ai-vault-types' import type { ExecutionHostScope } from '../../../../shared/execution-host' import { - aiVaultSessionDepthCovers, + aiVaultCachedDepthServes, truncateAiVaultListResult } from '../../../../shared/ai-vault-session-depth' import type { AiVaultSessionLimit } from './ai-vault-session-limit' @@ -30,7 +30,7 @@ export function readCachedAiVaultSessionResult(args: { scopePaths: readonly string[] }): AiVaultListResult | null { const cached = cachedSessionResults.get(args.key) - if (!cached || !aiVaultSessionDepthCovers(cached.limit, args.limit)) { + if (!cached || !aiVaultCachedDepthServes(cached.limit, args.limit, args.scopePaths)) { return null } cachedSessionResults.delete(args.key) @@ -42,6 +42,7 @@ export function cacheAiVaultSessionResult(args: { key: string executionHostScope: ExecutionHostScope limit: AiVaultSessionLimit + scopePaths: readonly string[] result: AiVaultListResult replaceHostEntries: boolean }): void { @@ -53,7 +54,7 @@ export function cacheAiVaultSessionResult(args: { } } else { const cached = cachedSessionResults.get(args.key) - if (cached && aiVaultSessionDepthCovers(cached.limit, args.limit)) { + if (cached && aiVaultCachedDepthServes(cached.limit, args.limit, args.scopePaths)) { return } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index 8b53eeaa7862..5054638316ef 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({ toastError: vi.fn<(message: string) => void>(), admitStructuredLaunchOnHost: vi.fn<(target: unknown, worktree: string, agent: string) => Promise<{ kind: string }>>(), + sleeping: false, + navigationIndex: 0, activeWorktreeId: 'other-worktree' })) @@ -34,7 +36,13 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/store', () => ({ - useAppStore: { getState: () => ({ activeWorktreeId: mocks.activeWorktreeId }) } + useAppStore: { + getState: () => ({ + activeWorktreeId: mocks.activeWorktreeId, + worktreeNavHistoryIndex: mocks.navigationIndex, + sleepingAgentSessionsByPaneKey: mocks.sleeping ? { source: { worktreeId: 'worktree-1' } } : {} + }) + } })) import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch' @@ -65,6 +73,9 @@ const session: AiVaultSession = { describe('resumeAiVaultSessionInNewChat', () => { beforeEach(() => { vi.clearAllMocks() + mocks.sleeping = false + mocks.navigationIndex = 0 + mocks.activeWorktreeId = 'other-worktree' mocks.prepareAiVaultSessionForResume.mockResolvedValue({ sessionId: 'provider-1' }) mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { @@ -129,8 +140,41 @@ describe('resumeAiVaultSessionInNewChat', () => { expect(mocks.admitStructuredLaunchOnHost).not.toHaveBeenCalled() }) + it.each(['refusal', 'success', 'newer-navigation'] as const)( + 'keeps sleeping History cold until actual creation: %s', + async (outcome) => { + mocks.sleeping = true + let settle!: (value: StructuredAgentLaunchSettlement) => void + const settlement = new Promise((resolve) => { + settle = resolve + }) + mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { + args.beforeOpen?.('session-1') + return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement } + }) + await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') + expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled() + if (outcome === 'newer-navigation') { + mocks.navigationIndex += 1 + } + settle( + outcome === 'refusal' + ? { kind: 'failed', error: new Error('refused') } + : { kind: 'structured', sessionId: 'session-1' } + ) + await settlement + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledTimes(outcome === 'success' ? 1 : 0) + expect(mocks.toastError).toHaveBeenCalledTimes(outcome === 'refusal' ? 1 : 0) + } + ) + it.each([ ['agent_session_conflict', undefined, 'Another chat is already holding this conversation.'], + [ + 'agent_session_conflict', + 'conversationHeldElsewhere', + "Orca can't confirm Codex has exited in its terminal. Stop what's running there or close the terminal, then try again." + ], [ 'agent_session_identity_required', 'transcriptNotFound', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index 13b1ad842031..e331653a6ff4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -18,7 +18,10 @@ import { } from '@/lib/structured-agent-session-provisional-tab' import { admitStructuredLaunchOnHost } from '@/lib/structured-agent-session-host-admission' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' +import { workspaceHasSleepingAgentSessions } from '@/lib/worktree-agent-activation-gate' +import { resolveWebSessionVisibleTabId } from '@/runtime/web-session-focus-intent' import type { TuiAgent } from '../../../../shared/tui-agent' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' @@ -62,24 +65,43 @@ export async function resumeAiVaultSessionInNewChat( worktreeId, resumeFrom: { providerSessionId: preparedSession.sessionId } }) + const selection = useAppStore.getState() + const waitForCreate = workspaceHasSleepingAgentSessions(selection, worktreeId) const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, ...(replacing ? { afterTabId: replacing.afterTabId, focusIntent: replacing.focusIntent } : {}), - beforeOpen: () => { - if (useAppStore.getState().activeWorktreeId !== worktreeId) { - return activateAiVaultResumeWorkspace(worktreeId) - } - return true - } + beforeOpen: waitForCreate + ? undefined + : () => { + if (useAppStore.getState().activeWorktreeId !== worktreeId) { + return activateAiVaultResumeWorkspace(worktreeId) + } + return true + } }) if (!launch) { replacing?.onSettled(null) return } void launch.settlement.then((settlement) => { + const state = useAppStore.getState() + if ( + waitForCreate && + settlement.kind === 'structured' && + state.activeWorktreeId !== worktreeId && + state.activeWorktreeId === selection.activeWorktreeId && + state.activeWorkspaceExecutionHostId === selection.activeWorkspaceExecutionHostId && + state.activeView === selection.activeView && + state.worktreeNavHistory === selection.worktreeNavHistory && + state.worktreeNavHistoryIndex === selection.worktreeNavHistoryIndex && + resolveWebSessionVisibleTabId(state, selection.activeWorktreeId ?? '') === + resolveWebSessionVisibleTabId(selection, selection.activeWorktreeId ?? '') + ) { + activateAiVaultResumeWorkspace(worktreeId) + } const handled = replacing?.onSettled(settlement) === true if (settlement.kind === 'failed' && !settlement.notified && !handled) { notifyAiVaultSessionResumeInChatFailure(settlement.error, rowAgent) @@ -120,16 +142,21 @@ function notifyAiVaultSessionResumeInChatFailure( error: unknown, rowAgent: CliConversationRowAgent ): void { - if (hasRuntimeRpcErrorCode(error, 'agent_session_conflict')) { + const failure = structuredLaunchFailure(error) + if ( + hasRuntimeRpcErrorCode(error, 'agent_session_conflict') || + failure?.code === 'agent_session_conflict' + ) { toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', - 'Another chat is already holding this conversation.' - ) + failure?.details?.reason === 'conversationHeldElsewhere' && !failure.owner + ? cliConversationExitGuidance(rowAgent) + : translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', + 'Another chat is already holding this conversation.' + ) ) return } - const failure = structuredLaunchFailure(error) if ( failure?.code === 'agent_session_identity_required' && failure.details?.reason === 'transcriptNotFound' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-delete-undo-precondition.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-delete-undo-precondition.test.tsx new file mode 100644 index 000000000000..5693430c1539 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-delete-undo-precondition.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { + FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE, + missingOrIdenticalPrecondition +} from '../../../../shared/file-write-disk-precondition' +import { getFileExplorerOperationOwner } from './file-explorer-operation-owner' +import { + clearFileExplorerUndoHistory, + fileExplorerHasRedo, + fileExplorerHasUndo, + undoFileExplorer +} from './fileExplorerUndoRedo' +import { useFileDeletion } from './useFileDeletion' + +const mocks = vi.hoisted(() => ({ + remove: vi.fn().mockResolvedValue(undefined), + write: vi.fn() +})) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Delete' })) +vi.mock('@/components/editor/editor-autosave', () => ({ + requestEditorFileSave: vi.fn().mockResolvedValue(undefined), + requestEditorSaveQuiesce: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('@/runtime/runtime-file-mutation-client', () => ({ + deleteRuntimePath: mocks.remove, + writeRuntimeFile: mocks.write +})) +vi.mock('@/runtime/runtime-file-read-client', () => ({ + readRuntimeFileContent: vi.fn().mockResolvedValue({ content: 'deleted body', isBinary: false }) +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +const initialState = useAppStore.getInitialState() +const root = '/project' +const worktreeId = `repo::${root}` + +beforeEach(() => { + vi.clearAllMocks() + clearFileExplorerUndoHistory() + useAppStore.setState({ + repos: [], + activeWorktreeId: worktreeId, + worktreesByRepo: { + repo: [makeWorktree({ id: worktreeId, repoId: 'repo', path: root, hostId: 'local' })] + } + }) +}) +afterEach(() => useAppStore.setState(initialState, true)) + +it('undo of a delete never replaces a file recreated at the same path', async () => { + mocks.write.mockResolvedValue({ ok: false, diskConflict: 'changed' }) + const { result } = renderHook(() => + useFileDeletion({ + activeWorktreeId: worktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) + await act(async () => + result.current.requestDelete({ + name: 'notes.txt', + path: `${root}/notes.txt`, + relativePath: 'notes.txt', + isDirectory: false, + depth: 0, + operationOwner: getFileExplorerOperationOwner(worktreeId) + }) + ) + await vi.waitFor(() => expect(fileExplorerHasUndo()).toBe(true)) + + await expect(undoFileExplorer()).rejects.toThrow(FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE) + expect(mocks.write.mock.calls[0]?.[4]).toEqual(missingOrIdenticalPrecondition('deleted body')) + // The refused step stays on the stack, so the user can undo again once the path is clear. + expect(fileExplorerHasUndo()).toBe(true) + expect(fileExplorerHasRedo()).toBe(false) + + mocks.write.mockResolvedValue({ ok: true }) + await expect(undoFileExplorer()).resolves.toBe(true) + expect(fileExplorerHasUndo()).toBe(false) + expect(fileExplorerHasRedo()).toBe(true) +}) + +it('drops an undo whose failure may have applied it, as before', async () => { + // A timed-out reply may follow a write that landed; replaying could replace a newer file. + mocks.write.mockRejectedValue(new Error('Timed out waiting for the remote Orca runtime')) + const { result } = renderHook(() => + useFileDeletion({ + activeWorktreeId: worktreeId, + openFiles: [], + closeFile: vi.fn(), + refreshDir: vi.fn().mockResolvedValue(undefined), + setSelectedPaths: vi.fn(), + isWindows: false + }) + ) + await act(async () => + result.current.requestDelete({ + name: 'notes.txt', + path: `${root}/notes.txt`, + relativePath: 'notes.txt', + isDirectory: false, + depth: 0, + operationOwner: getFileExplorerOperationOwner(worktreeId) + }) + ) + await vi.waitFor(() => expect(fileExplorerHasUndo()).toBe(true)) + + await expect(undoFileExplorer()).rejects.toThrow('Timed out') + expect(fileExplorerHasUndo()).toBe(false) +}) diff --git a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts index be3c9bd79ee8..24f3a4f3d212 100644 --- a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts +++ b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts @@ -4,6 +4,8 @@ * to reverse or replay the operation without relying on OS trash restore * (which is not exposed in a portable way here). */ +import { FileWriteRefusedError } from '../../../../shared/file-write-disk-precondition' + const MAX_STEPS = 50 type ExplorerOp = { @@ -32,7 +34,16 @@ export async function undoFileExplorer(): Promise { if (!op) { return false } - await op.undo() + try { + await op.undo() + } catch (error) { + // Why only a refusal: it wrote nothing, so the step stays retryable; any other failure (a + // timeout) may have applied it, and replaying could undo a newer change. + if (error instanceof FileWriteRefusedError) { + past.push(op) + } + throw error + } future.push(op) return true } @@ -42,7 +53,14 @@ export async function redoFileExplorer(): Promise { if (!op) { return false } - await op.redo() + try { + await op.redo() + } catch (error) { + if (error instanceof FileWriteRefusedError) { + future.push(op) + } + throw error + } past.push(op) return true } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx index 68fd0a912230..de5a4148369d 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx @@ -1,6 +1,6 @@ import React from 'react' import { MessageSquare } from 'lucide-react' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { basename, dirname, joinPath } from '@/lib/path' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { writeWorkspaceFileDragSourceForWorkspace } from '@/lib/workspace-file-drag-source' @@ -33,7 +33,6 @@ export function BranchEntryRow({ commentCount: number showPathHint?: boolean }): React.JSX.Element { - const FileIcon = getFileTypeIcon(entry.path) const fileName = basename(entry.path) const parentDir = dirname(entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -63,7 +62,8 @@ export function BranchEntryRow({ onClick={(e) => onOpen(e)} onDoubleClick={(e) => onOpen(toPermanentSourceControlRowOpenEvent(e))} > - {React.createElement(FileIcon, { + {React.createElement(FileTypeIcon, { + filePath: entry.path, className: 'size-3.5 shrink-0', style: { color: STATUS_COLORS[entry.status] } })} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx index d733b37e7cd5..a02cfaaa2397 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx @@ -1,6 +1,6 @@ import React from 'react' import { ChevronDown, MessageSquare, Minus, Plus, Trash, Undo2 } from 'lucide-react' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' @@ -71,7 +71,6 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ // When set, the row is a dirty submodule: clicking toggles lazy expansion instead of opening an uninformative gitlink diff. submoduleExpansion?: { isExpanded: boolean; onToggle: () => void } }): React.JSX.Element { - const FileIcon = getFileTypeIcon(entry.path) const fileName = basename(entry.path) const parentDir = dirname(entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -157,7 +156,8 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ )} /> )} - {React.createElement(FileIcon, { + {React.createElement(FileTypeIcon, { + filePath: entry.path, className: 'size-3.5 shrink-0', style: { color: STATUS_COLORS[entry.status] } })} diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx index 59461d14f1aa..19bcbb587755 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx @@ -7,7 +7,7 @@ import { toSourceControlRowOpenEvent, type SourceControlRowOpenEvent } from '../listing/split-open' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { basename, dirname } from '@/lib/path' import { translate } from '@/i18n/i18n' import { formatGitHistoryTimestamp } from './git-history-format' @@ -29,7 +29,6 @@ function CommitFileRow({ onOpen: (entry: GitBranchChangeEntry, event: SourceControlRowOpenEvent) => void }): React.JSX.Element { const status = entry.status as GitFileStatus - const FileIcon = getFileTypeIcon(entry.path) const fileName = basename(entry.path) const parentDir = dirname(entry.path) const dirPath = parentDir === '.' ? '' : parentDir @@ -43,7 +42,8 @@ function CommitFileRow({ onClick={(event) => onOpen(entry, toSourceControlRowOpenEvent(event))} onDoubleClick={(event) => onOpen(entry, toPermanentSourceControlRowOpenEvent(event))} > - {createElement(FileIcon, { + {createElement(FileTypeIcon, { + filePath: entry.path, className: 'size-3.5 shrink-0', style: { color: STATUS_COLORS[status] } })} diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 7d6ba0628a39..46d03ff84721 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -23,6 +23,10 @@ import { } from '@/components/editor/editor-autosave' import { commitFileExplorerOp } from './fileExplorerUndoRedo' import { deleteRuntimePath, writeRuntimeFile } from '@/runtime/runtime-file-mutation-client' +import { + assertFileWriteApplied, + missingOrIdenticalPrecondition +} from '../../../../shared/file-write-disk-precondition' import { readRuntimeFileContent } from '@/runtime/runtime-file-read-client' import { translate } from '@/i18n/i18n' @@ -166,14 +170,19 @@ export function useFileDeletion({ commitFileExplorerOp({ undo: async () => { const currentRoute = operationGuard.assertCurrent() - await writeRuntimeFile( - { - ...fileContext, - target: currentRoute.target, - connectionId: currentRoute.connectionId - }, - node.path, - undoContent + // Why: something may have recreated the path since the delete; undo must not replace it. + assertFileWriteApplied( + await writeRuntimeFile( + { + ...fileContext, + target: currentRoute.target, + connectionId: currentRoute.connectionId + }, + node.path, + undoContent, + undefined, + missingOrIdenticalPrecondition(undoContent) + ) ) await refreshDir(parentDir) }, diff --git a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx index abc56f419c5e..947be2a54181 100644 --- a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx @@ -7,6 +7,7 @@ import type { Repo } from '../../../../shared/repo-types' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import { useAppStore } from '@/store' import { McpConfigSection } from './McpConfigSection' +import { toast } from 'sonner' const runtimeFiles = vi.hoisted(() => ({ readRuntimeDirectory: vi.fn(), @@ -39,7 +40,7 @@ const desktopFs = { readDir: vi.fn(), readFile: vi.fn(), writeFile: vi.fn() } beforeEach(() => { vi.clearAllMocks() runtimeFiles.readRuntimeDirectory.mockResolvedValue([]) - runtimeFiles.writeRuntimeFile.mockResolvedValue(undefined) + runtimeFiles.writeRuntimeFile.mockResolvedValue({ ok: true }) desktopFs.readDir.mockResolvedValue([{ name: '.mcp.json', isDirectory: false }]) desktopFs.readFile.mockResolvedValue({ content: '{"mcpServers":{}}', isBinary: false }) Object.defineProperty(window, 'api', { @@ -97,7 +98,9 @@ it('inspects and creates a managed-host repo config on that host, never the desk expect(runtimeFiles.writeRuntimeFile).toHaveBeenCalledWith( expect.objectContaining({ target: { kind: 'environment', environmentId: 'host-a' } }), `${root}/.mcp.json`, - expect.any(String) + expect.any(String), + undefined, + expect.objectContaining({ allowMissing: true }) ) expect(desktopFs.writeFile).not.toHaveBeenCalled() }) @@ -128,3 +131,14 @@ it('does not offer the starter over a listed config the host refuses to read', a await screen.findByText('Remote file is too large') expect(screen.queryByRole('button', { name: /Add MCP config/ })).toBeNull() }) + +it('does not replace a config another process created after the listing', async () => { + runtimeFiles.writeRuntimeFile.mockResolvedValue({ ok: false, diskConflict: 'changed' }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /Add MCP config/ })) + fireEvent.click(await screen.findByRole('button', { name: /Create empty config/ })) + + await waitFor(() => expect(toast.error).toHaveBeenCalled()) + expect(toast.success).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 9c7ec7632fb7..17d1455423bb 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -33,6 +33,10 @@ import { loadMcpConfigInspections } from './mcp-config-inspection' import { translate } from '@/i18n/i18n' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { writeRuntimeFile } from '@/runtime/runtime-file-mutation-client' +import { + assertFileWriteApplied, + missingOrIdenticalPrecondition +} from '../../../../shared/file-write-disk-precondition' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' @@ -251,7 +255,16 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele : {} // Why: v1 only creates the root workspace config so we do not need to // guess per-agent directory layouts or mutate agent-specific files. - await writeRuntimeFile({ ...fileContext, ...sshExpectation }, target, MCP_STARTER_CONFIG) + // Why: the listing can be stale; never replace a config something else just created. + assertFileWriteApplied( + await writeRuntimeFile( + { ...fileContext, ...sshExpectation }, + target, + MCP_STARTER_CONFIG, + undefined, + missingOrIdenticalPrecondition(MCP_STARTER_CONFIG) + ) + ) clearCreateConfirmResetTimer() if (mountedRef.current) { setCreateConfirm(false) diff --git a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx index 5cae155414f5..da426f076bce 100644 --- a/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatProvidersSetting.tsx @@ -1,4 +1,3 @@ -import { useSyncExternalStore } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' @@ -7,15 +6,10 @@ import { isNativeChatProviderDisplayedOn } from '../../../../shared/native-chat-providers' import type { TuiAgent } from '../../../../shared/tui-agent' -import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' import { AgentIcon, getAgentCatalog, getAgentLabel } from '@/lib/agent-catalog' -import { - hostStructuredAgentsVersion, - readHostStructuredAgents, - subscribeHostStructuredAgents -} from '@/runtime/host-structured-agents' +import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents' import { useManagedRuntimeInstall } from '@/runtime/use-managed-runtime-install' import { useAppStore } from '../../store' import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state' @@ -40,18 +34,6 @@ const CHAT_HOST: RuntimeClientTarget = { kind: 'local' } const BUILT_IN_CHAT_AGENTS: readonly TuiAgent[] = ['claude', 'codex'] -/** Claude and Codex, then the other agents this computer's host registered for chat. */ -function useLocalChatAgents(): TuiAgent[] { - useSyncExternalStore(subscribeHostStructuredAgents, hostStructuredAgentsVersion) - const registered = readHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, undefined) ?? [] - return [ - ...new Set([ - ...BUILT_IN_CHAT_AGENTS, - ...registered.map((row) => row.agent).filter((agent) => isTuiAgent(agent)) - ]) - ] -} - function ProviderStatus({ agent, installed, @@ -167,7 +149,8 @@ export function NativeChatProvidersSetting({ forceVisible?: boolean }): React.JSX.Element | null { const entry = getChatProvidersSearchEntry() - const agents = useLocalChatAgents() + useHostStructuredAgentsVersion() + const agents = chatProviderAgents(LOCAL_EXECUTION_HOST_ID, undefined) const detectedIds = useAppStore(readNativeDetectedAgents) const visibleAgents = agents.filter( (agent) => diff --git a/src/renderer/src/components/settings/PluginConsentDialog.tsx b/src/renderer/src/components/settings/PluginConsentDialog.tsx index f0b39d05ba1b..7b0eaca54b2d 100644 --- a/src/renderer/src/components/settings/PluginConsentDialog.tsx +++ b/src/renderer/src/components/settings/PluginConsentDialog.tsx @@ -134,7 +134,9 @@ export function PluginConsentDialog({ }} > { // Why: dismissal is the safety-preserving path, so it receives initial focus. event.preventDefault() diff --git a/src/renderer/src/components/settings/PluginInstallDialog.tsx b/src/renderer/src/components/settings/PluginInstallDialog.tsx index 1b13bea59e1f..b9786db960a6 100644 --- a/src/renderer/src/components/settings/PluginInstallDialog.tsx +++ b/src/renderer/src/components/settings/PluginInstallDialog.tsx @@ -79,7 +79,7 @@ export function PluginInstallDialog({ return ( !installing && onOpenChange(nextOpen)}> - + {translate('auto.components.settings.PluginInstallDialog.title', 'Install plugin')} diff --git a/src/renderer/src/components/settings/PluginMarketplacePreviewDialog.tsx b/src/renderer/src/components/settings/PluginMarketplacePreviewDialog.tsx index 6f7e34fa6570..cdc3c35bde4d 100644 --- a/src/renderer/src/components/settings/PluginMarketplacePreviewDialog.tsx +++ b/src/renderer/src/components/settings/PluginMarketplacePreviewDialog.tsx @@ -152,7 +152,7 @@ export function PluginMarketplacePreviewDialog({ : undefined return ( !open && !busy && onClose()}> - + {preview ? ( <> diff --git a/src/renderer/src/components/settings/PluginMarketplaceSourceDialog.tsx b/src/renderer/src/components/settings/PluginMarketplaceSourceDialog.tsx index 89c901ff2a86..853919562702 100644 --- a/src/renderer/src/components/settings/PluginMarketplaceSourceDialog.tsx +++ b/src/renderer/src/components/settings/PluginMarketplaceSourceDialog.tsx @@ -121,7 +121,9 @@ export function PluginMarketplaceSourceDialog({ return ( !busyAction && onOpenChange(nextOpen)}> { event.preventDefault() urlRef.current?.focus() diff --git a/src/renderer/src/components/settings/PluginRemoveDialog.tsx b/src/renderer/src/components/settings/PluginRemoveDialog.tsx index 3cae9dd9aaf1..f9829e664702 100644 --- a/src/renderer/src/components/settings/PluginRemoveDialog.tsx +++ b/src/renderer/src/components/settings/PluginRemoveDialog.tsx @@ -29,6 +29,7 @@ export function PluginRemoveDialog({ return ( !open && !busy && onCancel()}> { event.preventDefault() cancelRef.current?.focus() diff --git a/src/renderer/src/components/settings/PluginRollbackDialog.tsx b/src/renderer/src/components/settings/PluginRollbackDialog.tsx index f8693f4829ee..21a03bf12ad5 100644 --- a/src/renderer/src/components/settings/PluginRollbackDialog.tsx +++ b/src/renderer/src/components/settings/PluginRollbackDialog.tsx @@ -31,6 +31,7 @@ export function PluginRollbackDialog({ return ( !open && !busy && onCancel()}> { event.preventDefault() cancelRef.current?.focus() diff --git a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx index 02f90518df39..20d1146ff9db 100644 --- a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx +++ b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx @@ -5,7 +5,7 @@ import { Button } from '../ui/button' import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '../ui/command' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { cn } from '@/lib/utils' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { SearchableSetting } from './SearchableSetting' import { getWorktreeSymlinkPathFilterState, @@ -181,7 +181,6 @@ export function WorktreeSymlinksSection({ ) : null} {filtered.map((entry) => { const alreadyAdded = paths.includes(entry.name) - const FileIcon = getFileTypeIcon(entry.name) return ( ) : ( - + )} {entry.name} {alreadyAdded ? ( diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts index 58472bef426d..88e831be0306 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { findCollidingEnvironmentName } from '../../../../shared/runtime-environment-name-collision' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details' import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert' @@ -58,9 +59,7 @@ export function useRuntimeEnvironmentMutationActions({ ) return } - const duplicate = environments.find( - (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase() - ) + const duplicate = findCollidingEnvironmentName(environments, trimmedName) if (duplicate) { toast.error( translate( diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx index 60ea9bcf90f3..a14a8990f810 100644 --- a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx @@ -104,7 +104,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { return ( - + {previouslyApproved diff --git a/src/renderer/src/components/sidebar/RemoteFileBrowserEntryList.tsx b/src/renderer/src/components/sidebar/RemoteFileBrowserEntryList.tsx index 62b82a6f307a..af8aae3f92df 100644 --- a/src/renderer/src/components/sidebar/RemoteFileBrowserEntryList.tsx +++ b/src/renderer/src/components/sidebar/RemoteFileBrowserEntryList.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useMemo, type RefObject } from 'react' import { ChevronRight, Folder, LoaderCircle } from 'lucide-react' import { cn } from '@/lib/utils' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { filterEntries, isRemoteFileBrowserPathResolveTextTooLarge, @@ -91,7 +91,6 @@ export function RemoteFileBrowserEntryList({
) : ( displayEntries.map((entry) => { - const FileIcon = getFileTypeIcon(entry.name) return ( + + ) +} + +function AgentSettingsMenuItem({ onSelect }: { onSelect: () => void }): React.JSX.Element { + return ( + + + {translate('auto.components.tab.bar.QuickLaunchButton.348a04c1ad', 'Agent settingsโ€ฆ')} + + ) +} + export const QuickLaunchAgentMenuItems = React.memo(QuickLaunchAgentMenuItemsInner) diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 8c5b649ba749..b6cf6641781a 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -142,7 +142,11 @@ vi.mock('./BrowserTab', () => ({ vi.mock('./QuickLaunchButton', () => ({ QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems() { return null - } + }, + ProjectedAgentMenuItems: function ProjectedAgentMenuItems() { + return null + }, + useTabAgentMenuLaunch: () => () => undefined })) vi.mock('./shell-icons', () => ({ diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 198e4a08af20..bfa49705bf8d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -28,6 +28,7 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { const createMenu = useTabBarCreateMenuController({ worktreeId, resolvedGroupId: runtime.resolvedGroupId, + showAgentLaunchItems: props.showAgentLaunchItems, terminalOnly, mobileEmulatorEnabled: runtime.mobileEmulatorEnabled, managedBrowserCreationEnabled: runtime.managedBrowserCreationEnabled, @@ -37,8 +38,8 @@ function TabBarInner(props: TabBarProps): React.JSX.Element { projectRuntimeShellMenuMode: runtime.projectRuntimeShellMenuMode, defaultWindowsShell: runtime.defaultWindowsShell, defaultWindowsPowerShellImplementation: runtime.defaultWindowsPowerShellImplementation, + agentDetectionTarget: runtime.agentDetectionTarget, windowsTerminalCapabilities: runtime.windowsTerminalCapabilities, - agentLaunchOptions: runtime.agentLaunchOptions, onNewTerminalTab, onNewTerminalWithShell, onNewBrowserTab, diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index ac80ca0699c2..8741efb10708 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -47,6 +47,7 @@ function TabBarCreateEntrySession({ menuOptions = EMPTY_MENU_OPTIONS, onDidOpenEntry, onLaunchAgent, + onLaunchAgentOption, onOpenDefaultTerminal, onOpenEntry, onQueryChange, @@ -230,7 +231,14 @@ function TabBarCreateEntrySession({ return } if (selectedOption.kind === 'agent') { - onLaunchAgent?.(selectedOption.option.agent) + if (selectedOption.option.disabled) { + return + } + if (onLaunchAgentOption) { + onLaunchAgentOption(selectedOption.option) + } else { + onLaunchAgent?.(selectedOption.option.agent) + } onDidOpenEntry?.() return } @@ -378,7 +386,11 @@ function TabBarCreateEntrySession({ id={resultOptionDomId(index)} option={option} selected={index === activeSelectedIndex} - disabled={disabled || pending} + disabled={ + disabled || + pending || + (option.kind === 'agent' && option.option.disabled === true) + } loading={pending && index === activeSelectedIndex} onClick={() => { setSelectionGuidance(null) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index bd8353906884..3c82667140e0 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -191,7 +191,7 @@ function getActionPresentation( } if (option.kind === 'agent') { return { - detail: option.option.label, + detail: [option.option.label, option.option.reason].filter(Boolean).join(' ยท '), icon: , label: labelOverride ?? diff --git a/src/renderer/src/components/tab-bar/TabDragPreview.tsx b/src/renderer/src/components/tab-bar/TabDragPreview.tsx index 25a44d41fe93..2458c803f38b 100644 --- a/src/renderer/src/components/tab-bar/TabDragPreview.tsx +++ b/src/renderer/src/components/tab-bar/TabDragPreview.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { Globe, Terminal as TerminalIcon } from 'lucide-react' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { FileTypeIcon } from '@/components/FileTypeIcon' import { AgentIcon } from '@/lib/agent-catalog' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -11,8 +11,10 @@ function LeadingIcon({ drag }: { drag: TabDragItemData }): React.JSX.Element { return } if (drag.tabType === 'editor') { - const FileIcon = getFileTypeIcon(drag.iconPath ?? drag.label) - return createElement(FileIcon, { className: 'h-3.5 w-3.5 shrink-0' }) + return createElement(FileTypeIcon, { + filePath: drag.iconPath ?? drag.label, + className: 'h-3.5 w-3.5 shrink-0' + }) } if (drag.agent) { return diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index adab289eee5c..2b2949f763f3 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -1,11 +1,11 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { agentSessionWriteFailureText } from '../native-chat/agent-session-write-notice-text' import { agentLabel } from '../../../../shared/ai-vault-session-filters' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' import type { CliChatResumeSource } from './cli-chat-resume-source' export async function reportCliChatResumeFailure( @@ -14,22 +14,25 @@ export async function reportCliChatResumeFailure( openOwner: boolean, target: RuntimeClientTarget ): Promise { - const refusal = - error instanceof StructuredAgentSessionCreateRefusalError ? error.refusal : undefined + const refusal = structuredLaunchFailure(error) if ( refusal?.code === 'agent_session_conflict' || refusal?.code === 'agent_session_ownership_unknown' ) { - const owner = refusal.details?.owner + const owner = refusal.owner if (openOwner && owner) { await activateAiVaultStructuredSession({ structuredSession: owner }, undefined, target) return } toast.error( - translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', - 'Another chat is already holding this conversation.' - ) + refusal.code === 'agent_session_conflict' && + refusal.details?.reason === 'conversationHeldElsewhere' && + !owner + ? cliConversationExitGuidance(source.agent, true) + : translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', + 'Another chat is already holding this conversation.' + ) ) return } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 6902d867543a..4b6e04dd2d8d 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -217,6 +217,26 @@ describe('CLI identity resume through the existing launch owner', () => { } }) + it('keeps the terminal and directs a refused terminal writer to Session History', async () => { + mocks.call.mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_conflict', + message: 'private host evidence', + details: { reason: 'conversationHeldElsewhere' }, + createAttemptRefusedBeforeCommit: true + } + }) + await runCliChatResume(SOURCE, true) + expect(mocks.toast).toHaveBeenCalledTimes(1) + expect(mocks.toast.mock.calls[0]?.[0]).toMatch( + /^Orca can't confirm Claude.* has exited in its terminal\. Stop what's running there or close the terminal, then open this conversation from Session History\.$/ + ) + expect(mocks.activate).not.toHaveBeenCalled() + expect(tabs()).toEqual([]) + expect([...structuredLaunchStates()]).toEqual([]) + }) + it('opens a proved owner only for the header, on the initiating host, without importing', async () => { const owner = { sessionId: 'owner-chat', workspaceId: 'owner-workspace' } mocks.call.mockResolvedValue({ diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts index bc99f0dace34..9be33f686081 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts @@ -5,6 +5,7 @@ import { getAgentCatalog, getAgentLabel } from '../../lib/agent-catalog' import { agentKindForAgentType, agentTypeToIconAgent } from '../../lib/agent-status' import { buildTabAgentLaunchOptions, + buildTabAgentLaunchOptionsFromRows, findMatchingTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' @@ -112,3 +113,16 @@ describe('tab agent launch options', () => { ) }) }) + +it('projects the same CLI label, unavailable reason and stable chat ID as static rows', () => { + const unavailable = buildTabAgentLaunchOptionsFromRows([ + { kind: 'chat-unavailable', agent: 'claude', reason: 'ssh' }, + { kind: 'cli', agent: 'claude' } + ]) + expect(unavailable).toMatchObject([ + { id: 'chat:claude', label: 'Claude', reason: 'No chat over SSH', disabled: true }, + { id: 'cli:claude', label: 'Claude (CLI)', disabled: false } + ]) + // Typing the agent's name and pressing Enter launches its CLI, not the disabled chat. + expect(findMatchingTabAgentLaunchOptions('claude', unavailable)[0]?.id).toBe('cli:claude') +}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts index 76c730e4a769..2b6986b7e69a 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts @@ -1,12 +1,18 @@ +import { rowLabel, rowId, chatUnavailableLabel } from './tab-agent-row-presentation' import { getAgentCatalog } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizeMatchQuery, tokenizeMatchValue } from './query-token-match' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { NewTabAgentMenuRow } from '@/lib/new-tab-agent-menu-rows' export type TabAgentLaunchOption = { agent: TuiAgent aliases: readonly string[] label: string + disabled?: boolean + id?: string + row?: NewTabAgentMenuRow + reason?: string } function normalizeAgentAlias(value: string): string { @@ -58,7 +64,30 @@ export function buildTabAgentLaunchOptions( aliases.add(normalizeAgentAlias(commandOverride)) aliases.add(compactAgentAlias(commandOverride)) } - return { agent, aliases: [...aliases], label } + return { + agent, + aliases: [...aliases], + label + } + }) +} + +export function buildTabAgentLaunchOptionsFromRows( + rows: readonly NewTabAgentMenuRow[], + commandOverrides: Partial> = {} +): TabAgentLaunchOption[] { + return rows.map((row) => { + const base = buildTabAgentLaunchOptions([row.agent], commandOverrides)[0] + return { + ...base, + agent: row.agent, + aliases: base?.aliases ?? [], + label: rowLabel(row), + disabled: row.kind === 'chat-unavailable', + reason: row.kind === 'chat-unavailable' ? chatUnavailableLabel(row.reason) : undefined, + row, + id: row.kind === 'terminal' ? `agent:${row.agent}` : rowId(row) + } }) } @@ -109,15 +138,21 @@ export function findMatchingTabAgentLaunchOptions( return [] } const compactQuery = compactAgentAlias(query) - return agents - .map((option, index) => ({ - index, - option, - score: scoreAgentLaunchOption(normalizedQuery, compactQuery, option) - })) - .filter((entry) => entry.score > 0) - .sort((left, right) => - left.score !== right.score ? right.score - left.score : left.index - right.index - ) - .map((entry) => entry.option) + return ( + agents + .map((option, index) => ({ + index, + option, + score: scoreAgentLaunchOption(normalizedQuery, compactQuery, option) + })) + .filter((entry) => entry.score > 0) + // Why: the first match takes Enter, so a row that can't launch never outranks one that can. + .sort( + (left, right) => + Number(Boolean(left.option.disabled)) - Number(Boolean(right.option.disabled)) || + right.score - left.score || + left.index - right.index + ) + .map((entry) => entry.option) + ) } diff --git a/src/renderer/src/components/tab-bar/tab-agent-row-presentation.ts b/src/renderer/src/components/tab-bar/tab-agent-row-presentation.ts new file mode 100644 index 000000000000..adc7d0b2e19f --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-agent-row-presentation.ts @@ -0,0 +1,57 @@ +import { translate } from '@/i18n/i18n' +import { getAgentCatalog } from '@/lib/agent-catalog' +import type { ChatUnavailableReason, NewTabAgentMenuRow } from '@/lib/new-tab-agent-menu-rows' + +export function chatUnavailableLabel(reason: ChatUnavailableReason): string { + switch (reason) { + case 'check-failed': + return translate( + 'auto.components.tab.bar.QuickLaunchButton.chatCheckFailed', + 'Couldnโ€™t check chat availability' + ) + case 'not-installed': + return translate( + 'auto.components.tab.bar.QuickLaunchButton.chatNotInstalled', + 'Not installed' + ) + case 'ssh': + return translate( + 'auto.components.tab.bar.QuickLaunchButton.chatNotOverSsh', + 'No chat over SSH' + ) + case 'wsl': + return translate('auto.components.tab.bar.QuickLaunchButton.chatNotInWsl', 'No chat in WSL') + case 'not-on-host': + return translate( + 'auto.components.tab.bar.QuickLaunchButton.chatNotOnHost', + 'No chat on this host' + ) + } +} + +export function rowTitle(row: NewTabAgentMenuRow, label: string): string { + return row.kind === 'chat' + ? translate( + 'auto.components.tab.bar.QuickLaunchButton.startChat', + 'Start a chat with {{value0}}', + { + value0: label + } + ) + : translate( + 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', + 'Launch {{value0}} in a new terminal', + { value0: label } + ) +} + +export function rowLabel(row: NewTabAgentMenuRow): string { + const label = getAgentCatalog().find((entry) => entry.id === row.agent)?.label ?? row.agent + return row.kind === 'cli' + ? `${label} ${translate('auto.components.tab.bar.QuickLaunchButton.cliTag', '(CLI)')}` + : label +} + +export function rowId(row: NewTabAgentMenuRow): string { + return `${row.kind === 'chat-unavailable' ? 'chat' : row.kind}:${row.agent}` +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 8c2072ea3f42..d166cb562736 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { QuickLaunchAgentMenuItems } from './QuickLaunchButton' +import { ProjectedAgentMenuItems } from './QuickLaunchButton' import TabBarCreateEntry from './TabBarCreateEntry' import { TabStripScrollIndicator } from './TabStripScrollIndicator' import { TabStripTooltipProvider } from './TabStripTooltipProvider' @@ -67,7 +67,6 @@ export function renderTabBarSurface({ showMobileEmulatorIntroCallout, windowsTerminalCapabilities, defaultWindowsPowerShellImplementation, - agentLaunchOptions, newTerminalShortcut, newBrowserShortcut, newSimulatorShortcut, @@ -75,6 +74,9 @@ export function renderTabBarSurface({ openMarkdownShortcut } = runtime const { + agentMenu, + agentLaunchOptions, + launchAgentRow, newTabMenuOpen, setNewTabMenuOpen, setCreateMenuQuery, @@ -84,7 +86,6 @@ export function renderTabBarSurface({ launchAgentFromNewTabEntry, runPendingNewTabMenuFocusAfterClose, queueNewActiveTerminalFocusAfterNewTabMenuClose, - queueTerminalTabFocusAfterNewTabMenuClose, queueFocusAfterNewTabMenuClose, showStaticCreateMenuItems } = createMenu @@ -238,7 +239,7 @@ export function renderTabBarSurface({ menuOpen={newTabMenuOpen} menuOptions={createMenuOptions} agentOptions={agentLaunchOptions} - onLaunchAgent={launchAgentFromNewTabEntry} + onLaunchAgentOption={launchAgentFromNewTabEntry} onOpenDefaultTerminal={() => { queueNewActiveTerminalFocusAfterNewTabMenuClose() onNewTerminalTab() @@ -277,11 +278,7 @@ export function renderTabBarSurface({ {showStaticCreateMenuItems && showAgentLaunchItems ? ( <> - + ) : null} diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts index e80d717bc32d..68591f0ddecd 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-render-stubs.ts @@ -102,7 +102,11 @@ export function stubQuickLaunchButton(): Record { return { QuickLaunchAgentMenuItems: function QuickLaunchAgentMenuItems() { return null - } + }, + ProjectedAgentMenuItems: function ProjectedAgentMenuItems() { + return null + }, + useTabAgentMenuLaunch: () => () => undefined } } diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts b/src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts index 925dd0119c0e..7f5c95a00b5c 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-active-option.ts @@ -45,7 +45,7 @@ export function isActiveEntryOption(option: TabEntryOption): option is ActiveEnt export function getActiveOptionId(option: ActiveOption): string { if (option.kind === 'agent') { - return `agent:${option.option.agent}` + return option.option.id ?? `agent:${option.option.agent}` } if (option.kind === 'menu') { return `menu:${option.option.id}` diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts b/src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts index 0e1fd266d0dd..b4731f7c6e9d 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-network-selection.ts @@ -27,6 +27,9 @@ export function useNetworkSafeTabEntrySelection({ const pinnedOptionIndex = pinnedOptionId ? activeOptions.findIndex((option) => getActiveOptionId(option) === pinnedOptionId) : -1 + const missingPinnedAgent = + pinnedOptionIndex < 0 && + (pinnedOptionId?.startsWith('chat:') || pinnedOptionId?.startsWith('cli:')) const rankedOption = pinnedOptionIndex < 0 ? activeOptions[0] : undefined const rankedClassification = rankedOption?.kind === 'entry' ? rankedOption.option.classification : null @@ -59,8 +62,9 @@ export function useNetworkSafeTabEntrySelection({ }, [fileIndexReady, hasRankedOption, rankedNetworkAction, rankingKey]) const networkActionAllowed = networkRankingSettled && blockedNetworkRankingRef.current !== rankingKey - const activeSelectedIndex = - pinnedOptionIndex >= 0 + const activeSelectedIndex = missingPinnedAgent + ? null + : pinnedOptionIndex >= 0 ? pinnedOptionIndex : activeOptions.length === 0 || (rankedNetworkAction && !networkActionAllowed) ? null diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts index ba44b1d932e7..f0862170e0c6 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-props.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-props.ts @@ -1,4 +1,3 @@ -import type { TuiAgent } from '../../../../shared/tui-agent' import type { TabCreateEntryArgs } from './tab-create-entry-action' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import type { TabCreateMenuOption } from './tab-create-menu-options' @@ -9,7 +8,9 @@ export type TabBarCreateEntryProps = { menuOpen: boolean menuOptions?: readonly TabCreateMenuOption[] onDidOpenEntry?: () => void - onLaunchAgent?: (agent: TuiAgent) => void + /** Legacy agent-only callback for callers that do not project surfaces. */ + onLaunchAgent?: (agent: TabAgentLaunchOption['agent']) => void + onLaunchAgentOption?: (option: TabAgentLaunchOption) => void onOpenDefaultTerminal?: () => void onOpenEntry?: (args: TabCreateEntryArgs) => Promise onQueryChange?: (query: string) => void diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx new file mode 100644 index 000000000000..7880e81d72d3 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultSettings } from '../../../../shared/constants' +import { + readLocalRuntimeCapabilitiesOrUnknown, + setLocalRuntimeCapabilitiesForTests +} from '@/runtime/local-runtime-capabilities' +import { + STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY as AVAILABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY as CATALOG +} from '../../../../shared/protocol-version' +const mocks = vi.hoisted(() => ({ + support: vi.fn(), + subscribe: vi.fn(), + publishing: false, + catalog: vi.fn(), + failed: vi.fn(), + load: vi.fn(), + detection: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-subscribe', () => ({ subscribeRuntimeRpc: mocks.subscribe })) +vi.mock('@/lib/structured-agent-session-host-admission', () => ({ + askHostCreateSupport: mocks.support +})) +// Like the real hook: a closed menu acquires nothing, and the stored inventory stays. +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: mocks.detection +})) +vi.mock('@/runtime/host-structured-agents', () => ({ + readHostStructuredAgents: mocks.catalog, + readHostStructuredAgentsForRuntime: mocks.catalog, + hostStructuredAgentsReadFailed: mocks.failed, + loadHostStructuredAgents: mocks.load, + hostStructuredAgentsVersion: () => 0, + subscribeHostStructuredAgents: () => () => {} +})) +vi.mock('@/lib/agent-launch-route-input', () => ({ + workspaceKindForWorktreeId: () => 'git-worktree', + buildAgentLaunchRouteInput: (_state: unknown, { agent }: { agent: string }) => ({ + agent, + executionHostId: 'local', + hostCapabilities: + readLocalRuntimeCapabilitiesOrUnknown() === null + ? null + : [CHAT, CLIENT, CATALOG, ...(mocks.publishing ? [AVAILABILITY] : [])], + clientCapabilities: [CHAT, CLIENT], + hostStructuredAgents: ['claude', 'codex', 'opencode'] + }) +})) +import { useNewTabAgentMenuRows } from './use-new-tab-agent-menu-rows' +const initial = useAppStore.getState() +beforeEach(() => { + mocks.support.mockReset() + mocks.subscribe.mockReset().mockResolvedValue({ unsubscribe: () => {} }) + mocks.publishing = false + mocks.catalog + .mockReset() + .mockReturnValue(['claude', 'codex', 'opencode'].map((agent) => ({ agent, capabilities: {} }))) + mocks.failed.mockReset().mockReturnValue(false) + mocks.load.mockReset().mockResolvedValue(undefined) + mocks.detection + .mockReset() + .mockReturnValue({ detectedIds: ['claude', 'codex', 'opencode', 'gemini'] }) + setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, CATALOG]) + useAppStore.setState({ + ...initial, + activeWorktreeId: 'one', + settings: { + ...getDefaultSettings('/home/me'), + experimentalNativeChat: true, + disabledTuiAgents: [] + }, + localDetectedAgentIdsByContext: { host: ['claude', 'codex'] }, + detectedAgentIds: ['claude', 'codex', 'opencode'] + }) +}) +afterEach(cleanup) + +it("reads a publishing host's one snapshot, holding a placeholder only until it lands", async () => { + mocks.publishing = true + mocks.catalog.mockReturnValue(undefined) + const hook = renderHook(({ id }) => useNewTabAgentMenuRows(id, false, { kind: 'local' }), { + initialProps: { id: 'one' } + }) + const row = (agent: string) => + hook.result.current.state === 'settled' + ? hook.result.current.rows.find((r) => r.agent === agent) + : undefined + // Held while the workspace is shown, so the first open already knows. + expect(mocks.subscribe).toHaveBeenCalledTimes(1) + expect(mocks.subscribe.mock.calls[0][2]).toEqual({}) + expect(hook.result.current.state).toBe('loading') + expect(hook.result.current).not.toHaveProperty('rows') + + act(() => + mocks.subscribe.mock.calls[0][3].onEvent({ + type: 'availability', + agents: [ + { agent: 'claude', state: 'refused', reason: 'agent' }, + { agent: 'codex', state: 'available' }, + { agent: 'opencode', state: 'unknown' } + ] + }) + ) + expect(row('claude')).toMatchObject({ reason: 'not-on-host' }) + expect(row('codex')).toMatchObject({ kind: 'chat' }) + + // Another workspace on the same host reads the same snapshot at once, without a new stream. + mocks.catalog.mockReturnValue([{ agent: 'gemini', capabilities: {} }]) + act(() => useAppStore.setState({ activeWorktreeId: 'two' })) + hook.rerender({ id: 'two' }) + // Past the microtask a released holder closes on. + await act(async () => {}) + expect(row('claude')).toMatchObject({ reason: 'not-on-host' }) + expect(row('gemini')).toMatchObject({ kind: 'cli' }) + expect(mocks.subscribe).toHaveBeenCalledTimes(1) + expect(mocks.support).not.toHaveBeenCalled() +}) + +it('shows an older host from client facts and never asks it per agent', () => { + mocks.publishing = false + const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' })) + expect( + hook.result.current.state === 'settled' && + hook.result.current.rows.find((r) => r.agent === 'claude') + ).toMatchObject({ + kind: 'chat' + }) + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.support).not.toHaveBeenCalled() +}) + +it('waits for a real catalog read, then preserves Gemini CLI after failure without retrying', () => { + mocks.catalog.mockReturnValue(undefined) + const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' })) + expect(mocks.load).toHaveBeenCalledTimes(1) + mocks.failed.mockReturnValue(true) + hook.rerender() + expect(hook.result.current.state === 'settled' && hook.result.current.rows).toContainEqual({ + kind: 'cli', + agent: 'gemini', + executionHostId: 'local' + }) + expect(mocks.load).toHaveBeenCalledTimes(1) +}) + +it('settles an initial inventory failure despite pending health and catalog enrichment', () => { + mocks.publishing = true + mocks.catalog.mockReturnValue(undefined) + mocks.detection.mockReturnValue({ detectedIds: null, detectionFailed: true }) + useAppStore.setState({ detectedAgentIds: null }) + const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' })) + expect( + hook.result.current.state === 'settled' && + hook.result.current.rows.every( + (row) => row.kind === 'chat-unavailable' && row.reason === 'check-failed' + ) + ).toBe(true) +}) + +it('holds initial capability acquisition, then exposes truthful failure and known CLI without a retry loop', async () => { + setLocalRuntimeCapabilitiesForTests(null) + const status = Promise.withResolvers() + const getStatus = vi.fn(() => status.promise) + Object.assign(window, { api: { runtime: { getStatus } } }) + const hook = renderHook(() => useNewTabAgentMenuRows('one', true, { kind: 'local' })) + await act(async () => { + status.reject(new Error('host unreachable')) + }) + expect(hook.result.current.state === 'settled' && hook.result.current.rows).toContainEqual({ + kind: 'chat-unavailable', + agent: 'claude', + reason: 'check-failed', + executionHostId: 'local' + }) + expect(hook.result.current.state === 'settled' && hook.result.current.rows).toContainEqual({ + kind: 'cli', + agent: 'gemini', + executionHostId: 'local' + }) + expect(getStatus).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts new file mode 100644 index 000000000000..2332c789440b --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-new-tab-agent-menu-rows.ts @@ -0,0 +1,255 @@ +import { useEffect, useSyncExternalStore } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { + STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY as AVAILABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY as CATALOG +} from '../../../../shared/protocol-version' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status' +import { runtimeHostContactForEntry } from '../../../../shared/runtime-host-contact' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' +import { useAppStore } from '@/store' +import { + structuredAgentLaunchSupport, + type AgentLaunchRoutingInput +} from '@/lib/agent-launch-routing' +import { + buildAgentLaunchRouteInput, + workspaceKindForWorktreeId +} from '@/lib/agent-launch-route-input' +import { useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + readHostStructuredAgents, + readHostStructuredAgentsForRuntime, + hostStructuredAgentsReadFailed, + loadHostStructuredAgents +} from '@/runtime/host-structured-agents' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { useHostStructuredAgentAvailability } from '@/runtime/use-host-structured-agent-availability' +import { + buildNewTabAgentMenuRows, + type HostChatAnswer, + type NewTabAgentMenuRow +} from '@/lib/new-tab-agent-menu-rows' +import { + readLocalRuntimeCapabilityObservation, + subscribeLocalRuntimeCapabilityObservation, + ensureLocalRuntimeCapabilities +} from '@/runtime/local-runtime-capabilities' +import { + useDetectedAgents, + type AgentDetectionTarget, + type UseDetectedAgentsResult +} from '@/hooks/useDetectedAgents' +import { readDetectedAgentsForTarget } from '@/lib/agent-detection-target-inventory' + +function hostChatAnswer(input: AgentLaunchRoutingInput): HostChatAnswer { + const support = structuredAgentLaunchSupport(input) + if (support.supported) { + return 'yes' + } + switch (support.blocker) { + case 'runtime-capability-unknown': + return 'not-yet-known' + case 'agent-without-structured-session': + return !input.hostStructuredAgents && input.hostCapabilities?.includes(CATALOG) !== false + ? 'not-yet-known' + : 'not-on-host' + case 'remote-execution-host': + return 'ssh' + case 'project-runtime': + return 'wsl' + case 'runtime-capability': + case 'client-capability': + return 'no-chats' + case 'custom-start-directory': + case 'reused-terminal': + return 'yes' + } +} + +export type NewTabAgentMenuModel = + | { state: 'loading'; placeholderCount: number } + | { state: 'settled'; rows: NewTabAgentMenuRow[]; detection: UseDetectedAgentsResult } + +function catalogUnlearned( + host: string, + runtimeId: string | null | undefined, + capabilities: readonly string[] | null | undefined +): boolean { + return ( + runtimeId !== undefined && + capabilities?.includes(CATALOG) === true && + !readHostStructuredAgentsForRuntime(host, runtimeId) && + !hostStructuredAgentsReadFailed(host, runtimeId) + ) +} + +/** `detectionTarget` is the workspace's agent-inventory host, resolved by the caller. */ +export function useNewTabAgentMenuRows( + worktreeId: string, + open: boolean, + detectionTarget: AgentDetectionTarget | undefined +): NewTabAgentMenuModel { + // Why: hidden worktrees stay mounted, so only the shown one holds its host's streams. + const worktreeShown = useAppStore((state) => state.activeWorktreeId === worktreeId) + // Why: acquiring while the workspace is shown means the first open already knows its agents. + const acquired = useDetectedAgents(open || worktreeShown ? detectionTarget : undefined) + // Closing the menu or leaving the workspace must not erase the known inventory. + const storedIds = useAppStore((state) => + detectionTarget ? readDetectedAgentsForTarget(state, detectionTarget) : null + ) + const detection = { ...acquired, detectedIds: acquired.detectedIds ?? storedIds } + useHostStructuredAgentsVersion() + const localCapabilities = useSyncExternalStore( + subscribeLocalRuntimeCapabilityObservation, + readLocalRuntimeCapabilityObservation, + readLocalRuntimeCapabilityObservation + ) + const settings = useAppStore((state) => state.settings) + const workspace = { kind: workspaceKindForWorktreeId(worktreeId), worktreeId } + const facts = useAppStore( + useShallow((state) => { + const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace }) + const host = parseExecutionHostId(input.executionHostId) + const status = + host?.kind === 'runtime' + ? state.runtimeStatusByEnvironmentId.get(host.environmentId) + : undefined + const contact = runtimeHostContactForEntry(status) + return { + executionHostId: input.executionHostId, + runtimeId: host?.kind === 'runtime' ? lastVerifiedRuntimeStatus(status)?.runtimeId : null, + pairingRevision: + host?.kind === 'runtime' ? getRuntimeEnvironmentRevision(host.environmentId) : undefined, + contact: `${status?.connectionGeneration}:${status?.hostContactEpoch}`, + checking: + contact.verdict === 'unverifiable' && + ['checking', 'transport-connecting'].includes(contact.reason), + capabilities: input.hostCapabilities?.join(',') + } + }) + ) + const state = useAppStore.getState() + const input = buildAgentLaunchRouteInput(state, { agent: 'claude', workspace }) + const chatEnabled = isNativeChatEnabled(settings) + const plain = !chatEnabled || hostChatAnswer(input) === 'no-chats' + const local = facts.executionHostId === LOCAL_EXECUTION_HOST_ID + // Why a branch: a host publishing availability answers every agent from its one health snapshot; + // an older host shows this client's facts, and the click's admission states the host's refusal. + const published = + input.hostCapabilities?.includes(AVAILABILITY) === true && + Boolean(structuredAgentSessionTargetForHost(facts.executionHostId)) + const availability = useHostStructuredAgentAvailability( + published && !plain + ? { + executionHostId: facts.executionHostId, + runtimeId: facts.runtimeId, + pairingRevision: facts.pairingRevision, + contact: facts.contact, + hold: worktreeShown || open + } + : null + ) + const roster = availability?.state === 'received' ? [...availability.byAgent.keys()] : undefined + const localCatalog = + local && roster + ? roster + : readHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, state.runtimeStatusByEnvironmentId)?.map( + ({ agent }) => agent + ) + const hostCatalog = + roster ?? + readHostStructuredAgents(facts.executionHostId, state.runtimeStatusByEnvironmentId)?.map( + ({ agent }) => agent + ) + const chatAgents = [ + ...new Set([ + 'claude', + 'codex', + ...(localCatalog ?? []), + ...(hostCatalog ?? []), + ...Object.keys(settings?.nativeChatProviders ?? {}) + .filter(isTuiAgent) + .filter((agent) => settings?.nativeChatProviders?.[agent] === true) + ]) + ].filter(isTuiAgent) + const inventoryFailed = + detection.detectedIds === null && (detection.detectionFailed || detection.needsServerUpdate) + const localAcquiring = + localCapabilities.state === 'unlearned' || localCapabilities.state === 'pending' + const capabilityPending = + input.hostCapabilities == null && (local ? localAcquiring : facts.checking) + const hostCatalogPending = + !roster && + availability?.state !== 'pending' && + catalogUnlearned(facts.executionHostId, facts.runtimeId, input.hostCapabilities) + const localCatalogPending = + !local && + catalogUnlearned( + LOCAL_EXECUTION_HOST_ID, + null, + localCapabilities.state === 'known' ? localCapabilities.capabilities : null + ) + useEffect(() => { + if ((!open && !worktreeShown) || plain || inventoryFailed) { + return + } + if (localCapabilities.state === 'unlearned') { + void ensureLocalRuntimeCapabilities() + } + if (hostCatalogPending && facts.runtimeId !== undefined) { + void loadHostStructuredAgents(facts.executionHostId, input.hostCapabilities, facts.runtimeId) + } + if (localCatalogPending && localCapabilities.state === 'known') { + void loadHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, localCapabilities.capabilities, null) + } + }, [ + open, + worktreeShown, + plain, + inventoryFailed, + localCapabilities, + hostCatalogPending, + localCatalogPending, + facts, + input.hostCapabilities + ]) + // Only dependencies that can enable an action hold the cold fill; a failed inventory is terminal. + if ( + !inventoryFailed && + ((detection.detectedIds === null && detectionTarget !== undefined) || + (!plain && + (capabilityPending || + (!local && localAcquiring) || + availability?.state === 'pending' || + hostCatalogPending || + localCatalogPending))) + ) { + return { + state: 'loading', + placeholderCount: Math.max(chatAgents.length, detection.detectedIds?.length ?? 0, 2) + } + } + const rows = buildNewTabAgentMenuRows({ + settings, + detectedOnHost: detection.detectedIds, + chatAgents, + hostChatAnswer: (agent) => { + const route = buildAgentLaunchRouteInput(state, { agent, workspace }) + const preliminary = hostChatAnswer( + roster ? { ...route, hostStructuredAgents: roster } : route + ) + if (!roster || !['yes', 'not-yet-known', 'not-on-host'].includes(preliminary)) { + return preliminary + } + const verdict = + availability?.state === 'received' ? availability.byAgent.get(agent)?.state : undefined + // An agent the host could not check keeps this client's facts; the click's admission decides. + return !verdict || verdict === 'refused' ? 'not-on-host' : preliminary + } + }).map((row) => ({ ...row, executionHostId: parseExecutionHostId(facts.executionHostId)?.id })) + return { state: 'settled', rows, detection } +} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 4ea998ac0b30..2d65e6782e99 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -1,17 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' -import type { TuiAgent } from '../../../../shared/tui-agent' -import { translate } from '@/i18n/i18n' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { - launchAgentInNewTab, - shouldQueueTerminalFocusAfterMenuClose -} from '@/lib/launch-agent-in-new-tab' -import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import { useTabAgentMenuLaunch } from './QuickLaunchButton' +import { useNewTabAgentMenuRows, type NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows' +import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' +import { buildTabAgentLaunchOptionsFromRows } from './tab-agent-launch-options' import { buildTabCreateMenuOptions, type TabCreateMenuOption } from './tab-create-menu-options' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' import { @@ -22,18 +17,19 @@ import type { getProjectRuntimeShellMenuMode, resolveWindowsPowerShellImplementationSetting } from './use-tab-bar-runtime-model' - const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50 const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000 - export type TabBarCreateMenuController = { + agentMenu: NewTabAgentMenuModel + agentLaunchOptions: TabAgentLaunchOption[] + launchAgentRow: ReturnType newTabMenuOpen: boolean setNewTabMenuOpen: (open: boolean) => void setCreateMenuQuery: (query: string) => void createMenuOptions: TabCreateMenuOption[] windowsShellEntries: WindowsShellMenuEntry[] | undefined handleSelectCreateMenuOption: (option: TabCreateMenuOption) => void - launchAgentFromNewTabEntry: (agent: TuiAgent) => void + launchAgentFromNewTabEntry: (option: TabAgentLaunchOption) => void runPendingNewTabMenuFocusAfterClose: () => void clearPendingNewTabMenuFocusOnUnmount: (node: HTMLDivElement | null) => void queueNewActiveTerminalFocusAfterNewTabMenuClose: () => void @@ -41,11 +37,11 @@ export type TabBarCreateMenuController = { queueFocusAfterNewTabMenuClose: (focus: () => void) => void showStaticCreateMenuItems: boolean } - export function useTabBarCreateMenuController({ worktreeId, resolvedGroupId, terminalOnly, + showAgentLaunchItems = true, mobileEmulatorEnabled, managedBrowserCreationEnabled, mobileEmulatorCreationEnabled, @@ -54,8 +50,8 @@ export function useTabBarCreateMenuController({ projectRuntimeShellMenuMode, defaultWindowsShell, defaultWindowsPowerShellImplementation, + agentDetectionTarget, windowsTerminalCapabilities, - agentLaunchOptions, onNewTerminalTab, onNewTerminalWithShell, onNewBrowserTab, @@ -66,6 +62,7 @@ export function useTabBarCreateMenuController({ worktreeId: string resolvedGroupId: string terminalOnly: boolean + showAgentLaunchItems?: boolean mobileEmulatorEnabled: boolean managedBrowserCreationEnabled: boolean mobileEmulatorCreationEnabled: boolean @@ -76,8 +73,8 @@ export function useTabBarCreateMenuController({ defaultWindowsPowerShellImplementation: ReturnType< typeof resolveWindowsPowerShellImplementationSetting > + agentDetectionTarget: AgentDetectionTarget | undefined windowsTerminalCapabilities: WindowsTerminalCapabilities - agentLaunchOptions: TabAgentLaunchOption[] onNewTerminalTab: () => void onNewTerminalWithShell?: (shell: string) => void onNewBrowserTab: () => void @@ -88,6 +85,16 @@ export function useTabBarCreateMenuController({ // Why: clicks are out-of-process, so Radix's document-pointerdown outside-click check misses them; use window blur. const [newTabMenuOpen, setNewTabMenuOpen] = useState(false) const [createMenuQuery, setCreateMenuQuery] = useState('') + const agentMenu = useNewTabAgentMenuRows( + worktreeId, + newTabMenuOpen && showAgentLaunchItems, + agentDetectionTarget + ) + const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) + const agentLaunchOptions = buildTabAgentLaunchOptionsFromRows( + agentMenu.state === 'settled' ? agentMenu.rows : [], + commandOverrides + ) const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) const pendingNewTabMenuFocusAnimationRef = useRef(null) const pendingNewTabMenuFocusRetryRef = useRef(null) @@ -221,32 +228,16 @@ export function useTabBarCreateMenuController({ onNewSimulatorTab?.() } } - const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { - const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) - const result = launchAgentInNewTab({ - requestId: newAgentLaunchRequestId(), - focusIntent: captureNativeChatComposerFocusIntent(), - agent, - worktreeId, - groupId: resolvedGroupId, - launchSource: 'tab_bar_quick_launch' - }) - if (!result) { - toast.error( - translate( - 'auto.components.tab.bar.TabBar.ab589350e5', - 'Could not build launch command for {{value0}}.', - { value0: option?.label ?? agent } - ) - ) - return - } - if (result.surface.kind === 'local-terminal') { - queueTerminalTabFocusAfterNewTabMenuClose(result.surface.tabId) - return - } - if (shouldQueueTerminalFocusAfterMenuClose(result)) { - queueNewActiveTerminalFocusAfterNewTabMenuClose() + const launchAgentRow = useTabAgentMenuLaunch({ + worktreeId, + groupId: resolvedGroupId, + launchSource: 'tab_bar_quick_launch', + onFocusTerminal: queueTerminalTabFocusAfterNewTabMenuClose, + onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose + }) + const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => { + if (option.row) { + launchAgentRow(option.row) } } const runPendingNewTabMenuFocusAfterClose = (): void => { @@ -275,7 +266,6 @@ export function useTabBarCreateMenuController({ } } const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current - useEffect(() => { if (!newTabMenuOpen) { return @@ -284,14 +274,15 @@ export function useTabBarCreateMenuController({ window.addEventListener('blur', dismiss) return () => window.removeEventListener('blur', dismiss) }, [newTabMenuOpen]) - useEffect(() => { if (!newTabMenuOpen) { setCreateMenuQuery('') } }, [newTabMenuOpen]) - return { + agentMenu, + agentLaunchOptions, + launchAgentRow, newTabMenuOpen, setNewTabMenuOpen, setCreateMenuQuery, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 2a6bb370cde8..c031b5d4c4c3 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -3,11 +3,10 @@ import { useShallow } from 'zustand/react/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Tab } from '../../../../shared/tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { useAppStore } from '../../store' import { buildStatusMap } from '../right-sidebar/status-display' -import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTarget' import { getConnectionIdFromState } from '@/lib/connection-context' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -25,9 +24,6 @@ import { selectTabBarAgentProjections, type TabBarAgentProjections } from './tab-agent-types-by-tab-id' -import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' -import type { TabAgentLaunchOption } from './tab-agent-launch-options' -import { DEFAULT_DISABLED_TUI_AGENTS } from '../../../../shared/tui-agent-selection' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { createUnifiedTabLookup } from './tab-bar-item-model' import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' @@ -37,7 +33,6 @@ export const isMacOs = navigator.userAgent.includes('Mac') type AppStoreState = ReturnType type GitStatusEntries = AppStoreState['gitStatusByWorktree'][string] const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] -const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} const EMPTY_UNIFIED_TABS: readonly Tab[] = [] const EMPTY_PROJECTS: AppStoreState['projects'] = [] const EMPTY_REPOS: AppStoreState['repos'] = [] @@ -72,10 +67,10 @@ export type TabBarRuntimeModel = { pinTab: (tabId: string) => void unpinTab: (tabId: string) => void defaultWindowsShell: string + agentDetectionTarget: AgentDetectionTarget | undefined defaultWindowsPowerShellImplementation: ReturnType< typeof resolveWindowsPowerShellImplementationSetting > - agentLaunchOptions: TabAgentLaunchOption[] windowsTerminalCapabilities: WindowsTerminalCapabilities showWindowsShellMenu: boolean projectRuntimeShellMenuMode: ReturnType @@ -121,6 +116,7 @@ export function useTabBarRuntimeModel({ const defaultWindowsShell = useAppStore( (s) => s.settings?.terminalWindowsShell ?? 'powershell.exe' ) + const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) const defaultWindowsPowerShellImplementation = useAppStore((s) => resolveWindowsPowerShellImplementationSetting(s.settings) ) @@ -141,23 +137,6 @@ export function useTabBarRuntimeModel({ } return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null }) - const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) - const disabledTuiAgents = useAppStore( - (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS - ) - const agentCmdOverrides = useAppStore( - (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES - ) - const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) - const { detectedIds } = useDetectedAgents(agentDetectionTarget) - const agentLaunchOptions = useMemo( - () => - buildTabAgentLaunchOptions( - orderTabLaunchAgents(defaultAgent, detectedIds ?? [], disabledTuiAgents), - agentCmdOverrides - ), - [agentCmdOverrides, defaultAgent, detectedIds, disabledTuiAgents] - ) const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( activeRuntimeEnvironmentId, @@ -269,8 +248,8 @@ export function useTabBarRuntimeModel({ pinTab, unpinTab, defaultWindowsShell, + agentDetectionTarget, defaultWindowsPowerShellImplementation, - agentLaunchOptions, windowsTerminalCapabilities, showWindowsShellMenu, projectRuntimeShellMenuMode, diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts index aacbcd5810ed..3f4104a967bf 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.test.ts @@ -7,7 +7,7 @@ import { markCommittedPtyShutdowns, settleDeferredPtyShutdownExits } from './pty-shutdown-exit-deferral' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { bufferPtyShutdownData, bufferPtyShutdownReplayData, diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts index de6023303a04..59d34b4f0861 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-exit-deferral.ts @@ -1,5 +1,5 @@ import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { unregisterPtyDataHandlers, type PtyDataHandlerShutdownSnapshot diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts index d4df5e2f9b68..23cc81a46792 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts @@ -72,27 +72,6 @@ describe('createRemotePaneLayoutPusher', () => { }) }) - it('adds only the intent field to a gesture push', () => { - const layout = makeLayout() - createRemotePaneLayoutPusher().push({ worktreeId: 'wt-1', tabId: 'tab-1', layout }) - createRemotePaneLayoutPusher().push({ - worktreeId: 'wt-1', - tabId: 'tab-1', - layout, - intent: 'gesture' - }) - const [[unmarked], [marked]] = updateWebRuntimePaneLayout.mock.calls - expect(unmarked).not.toHaveProperty('intent') - expect(marked).toEqual({ ...unmarked, intent: 'gesture' }) - }) - - it('dedupes a gesture push of an unchanged layout like any other push', () => { - const pusher = createRemotePaneLayoutPusher() - pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout() }) - pusher.push({ worktreeId: 'wt-1', tabId: 'tab-1', layout: makeLayout(), intent: 'gesture' }) - expect(updateWebRuntimePaneLayout).toHaveBeenCalledTimes(1) - }) - it('omits titlesByLeafId when the layout carries no titles', () => { const pusher = createRemotePaneLayoutPusher() pusher.push({ diff --git a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts index bc9e39df5170..71f199a752a9 100644 --- a/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts +++ b/src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts @@ -1,5 +1,4 @@ import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { terminalLayoutEqual } from '../../../../shared/terminal-layout-equality' import { updateWebRuntimePaneLayout } from '@/runtime/web-runtime-terminal-actions' import { @@ -13,7 +12,6 @@ export type RemotePaneLayoutPusher = { worktreeId: string tabId: string layout: TerminalLayoutSnapshot - intent?: PaneLayoutEditIntent }) => void } @@ -31,7 +29,7 @@ export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher { } | null = null let nextAttemptId = 0 return { - push: ({ worktreeId, tabId, layout, intent }) => { + push: ({ worktreeId, tabId, layout }) => { // Why: a resent full title map is not a rename; only these panes were renamed here. // A rename is always sent, even back to a title the last push carried. const renamedLeafIds = takeUnsentPaneTitleRenames(collectLeafIdsInOrder(layout.root)) @@ -52,8 +50,7 @@ export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher { expandedLeafId: layout.expandedLeafId, chatLeafId: layout.chatLeafId ?? null, ...(layout.titlesByLeafId ? { titlesByLeafId: layout.titlesByLeafId } : {}), - ...(renamedLeafIds.length > 0 ? { renamedLeafIds } : {}), - ...(intent ? { intent } : {}) + ...(renamedLeafIds.length > 0 ? { renamedLeafIds } : {}) }).then((updated) => { if (!updated) { requeuePendingPaneTitleRenames(renamedLeafIds) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts index 7844e4bb8d55..810fe81b805b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts @@ -18,6 +18,7 @@ import { type MultiplexSubscriptionCallbacks } from './remote-runtime-pty-transport-test-harness' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS } from './remote-runtime-pty-recovery-state' +import type { PtyTransport } from './pty-transport-types' let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' @@ -322,4 +323,256 @@ describe('createRemoteRuntimePtyTransport', () => { vi.useRealTimers() } }) + + it('refuses OMP thinking replay when the replacement host cannot preserve it', async () => { + vi.useFakeTimers() + let transport: PtyTransport | undefined + try { + let supportsThinking = true + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + ...(supportsThinking ? ['agent.launch.omp-thinking.v1'] : []) + ] + } + } + } + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + }) + // Reset modules gives each transport a fresh runtime capability cache. + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'omp', + agentLaunchPreferences: { model: 'opus', effort: 'off' } + }) + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + await connect + const initialCreates = runtimeCall.mock.calls.filter( + ([args]) => args.method === 'terminal.createAgentSession' + ).length + expect(initialCreates).toBeGreaterThan(0) + + supportsThinking = false + expect(transport.retryRecovery?.()).toBe(true) + await vi.waitFor(() => expect(onError).toHaveBeenCalled()) + expect( + runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.createAgentSession') + ).toHaveLength(initialCreates) + expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false) + expect(transport.getPtyId()).toBeNull() + } finally { + transport?.destroy?.() + vi.useRealTimers() + } + }) + + it('refuses automatic OMP replay after pairing invalidates the first host capability', async () => { + vi.useFakeTimers() + let transport: PtyTransport | undefined + try { + // Each fixture reloads the transport and its production capability cache together. + const { clearRuntimeCompatibilityCache } = await import('../../runtime/runtime-rpc-client') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + let supportsThinking = true + let creates = 0 + const operationIds: (string | undefined)[] = [] + runtimeCall.mockImplementation( + async (args: { method: string; params?: { clientOperationId?: string } }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + ...(supportsThinking ? ['agent.launch.omp-thinking.v1'] : []) + ] + } + } + } + if (args.method === 'terminal.createAgentSession') { + creates += 1 + operationIds.push(args.params?.clientOperationId) + if (creates === 1) { + supportsThinking = false + clearRuntimeCompatibilityCache('env-1') + } else { + return { + ok: true, + result: { + disposition: 'replayed', + terminal: { handle: 'terminal-thinking-replayed' } + } + } + } + } + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + ) + const onError = vi.fn() + transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'omp', + agentLaunchPreferences: { model: 'opus', effort: 'off' } + }) + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + await connect + + expect(creates).toBe(1) + expect(onError).toHaveBeenCalled() + expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(false) + expect(transport.getPtyId()).toBeNull() + + supportsThinking = true + clearRuntimeCompatibilityCache('env-1') + expect(transport.retryRecovery?.()).toBe(true) + const replayTransport = transport + await vi.waitFor(() => + expect(replayTransport.getPtyId()).toBe('remote:env-1@@terminal-thinking-replayed') + ) + expect(operationIds[0]).toMatch(/\S+/) + expect(operationIds).toEqual([operationIds[0], operationIds[0]]) + } finally { + transport?.destroy?.() + vi.useRealTimers() + } + }) + + it('refuses automatic legacy OMP create after thinking capability is invalidated', async () => { + vi.useFakeTimers() + let transport: PtyTransport | undefined + try { + // Pairing invalidation must affect the same production cache used by this transport. + const { clearRuntimeCompatibilityCache } = await import('../../runtime/runtime-rpc-client') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + let supportsThinking = true + let creates = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + ...(supportsThinking ? ['agent.launch.omp-thinking.v1'] : []) + ] + } + } + } + if (args.method === 'terminal.create') { + creates += 1 + if (creates === 1) { + supportsThinking = false + clearRuntimeCompatibilityCache('env-1') + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return { ok: true, result: { terminal: { handle: 'incorrect-legacy-create' } } } + } + return { ok: true, result: {} } + }) + const onError = vi.fn() + transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'omp', + agentLaunchPreferences: { model: 'opus', effort: 'off' } + }) + const connect = transport.connect({ url: '', callbacks: { onError } }) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + await connect + expect(creates).toBe(1) + expect(onError).toHaveBeenCalled() + expect(transport.getPtyId()).toBeNull() + } finally { + transport?.destroy?.() + vi.useRealTimers() + } + }) + + it.each(['host-authority', 'legacy'] as const)( + 'does not invent uncertain %s creation when only the thinking preflight times out', + async (lane) => { + vi.useFakeTimers() + let transport: PtyTransport | undefined + try { + let statusCalls = 0 + const creates: { method: string; params?: { reconcileExisting?: boolean } }[] = [] + runtimeCall.mockImplementation( + async (args: { method: string; params?: { reconcileExisting?: boolean } }) => { + if (args.method === 'status.get') { + statusCalls += 1 + if (statusCalls > 1) { + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent.launch.omp-thinking.v1', + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + ...(lane === 'host-authority' ? ['agent-session.host-authority.v1'] : []) + ] + } + } + } + if ( + args.method === 'terminal.create' || + args.method === 'terminal.createAgentSession' + ) { + creates.push(args) + return { ok: true, result: { terminal: { handle: 'terminal-after-preflight' } } } + } + return { ok: true, result: {} } + } + ) + // Reloaded transport and capability cache are paired by the fixture. + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1', + launchAgent: 'omp', + terminalKittyKeyboardProtocol: true, + agentLaunchPreferences: { model: 'opus', effort: 'off' } + }) + const options = { url: '', callbacks: {} } + const first = transport.connect(options) + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) + await first + expect(creates).toEqual([]) + expect(transport.retryRecovery?.()).toBe(false) + } finally { + transport?.destroy?.() + vi.useRealTimers() + } + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index e5b935044250..47f2fa884a79 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -51,9 +51,9 @@ import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '../../runtime/runti import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle, - runtimeTerminalErrorMessage, - toRemoteRuntimePtyId + runtimeTerminalErrorMessage } from '../../runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { getRemoteRuntimeTerminalMultiplexer } from '../../runtime/remote-runtime-terminal-multiplexer' import { REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE } from '../../runtime/remote-runtime-terminal-snapshot-state' import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage' @@ -89,7 +89,10 @@ import { import { listRemoteRuntimeSessionTabsAfterCurrentInFlight } from '@/runtime/remote-runtime-session-tabs-inflight' import { subscribeAcceptedWebSessionTerminalHandle } from '@/runtime/web-session-terminal-handle-events' import { hostSnapshotAffirmsWorktreeContents } from '@/runtime/host-session-snapshot-authority' -import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' +import { + assertRemoteAgentThinkingSupported, + runRemoteAgentSessionLaunch +} from '@/runtime/remote-agent-session-launch' import { useAppStore } from '@/store' import { recordWebAgentSessionHandoff } from '@/runtime/web-agent-session-handoff' import { refreshWebRuntimeSessionTabsSnapshot } from '@/runtime/web-runtime-session-snapshot' @@ -1260,7 +1263,8 @@ export function createRemoteRuntimePtyTransport( kind: 'terminal' | 'agent-session', invoke: ( timeoutMs: number, - reconcileExisting: boolean + reconcileExisting: boolean, + markCreateStarted: () => void ) => Promise, environmentId: string, expectedLifecycleEpoch: number @@ -1349,21 +1353,30 @@ export function createRemoteRuntimePtyTransport( if (createRemainingMs !== null && createRemainingMs <= 0) { break } + let createStarted = false try { - return await invoke(Math.min(15_000, createRemainingMs ?? 15_000), reconcileExisting) + return await invoke( + Math.min(15_000, createRemainingMs ?? 15_000), + reconcileExisting, + () => { + createStarted = true + } + ) } catch (error) { lastError = error const clientError = toRemoteRuntimeClientErrorLike(error) if (!isRecoverableRemoteRuntimeConnectionError(clientError)) { throw error } - if (kind === 'agent-session') { - agentSessionRequiresHostAuthorityReplay = true - } else { - terminalCreateNeedsReconciliation = true + if (createStarted) { + if (kind === 'agent-session') { + agentSessionRequiresHostAuthorityReplay = true + } else { + terminalCreateNeedsReconciliation = true + } + terminalCreateUnknownOutcomeError ??= error + reconcileExisting = true } - terminalCreateUnknownOutcomeError ??= error - reconcileExisting = true const startsRecovery = recoveryDeadlineAt === null recoveryDeadlineAt ??= Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS if (startsRecovery && !recovery.isActive) { @@ -3115,8 +3128,16 @@ export function createRemoteRuntimePtyTransport( const legacyCreate = () => createWithUnknownOutcomeRecovery( 'terminal', - (timeoutMs, reconcileExisting) => - callRuntimeForEnvironment<{ terminal: RuntimeTerminalCreate }>( + async (timeoutMs, reconcileExisting, markCreateStarted) => { + if (launchAgentToSend === 'omp') { + await assertRemoteAgentThinkingSupported({ + environmentId: createEnvironmentId, + agent: launchAgentToSend, + launchPreferences: agentLaunchPreferences + }) + } + markCreateStarted() + return callRuntimeForEnvironment<{ terminal: RuntimeTerminalCreate }>( createEnvironmentId, 'terminal.create', { @@ -3124,7 +3145,8 @@ export function createRemoteRuntimePtyTransport( ...(reconcileExisting ? { reconcileExisting: true } : {}) }, timeoutMs - ), + ) + }, createEnvironmentId, connectLifecycleEpoch ) @@ -3132,8 +3154,16 @@ export function createRemoteRuntimePtyTransport( const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) return createWithUnknownOutcomeRecovery( 'agent-session', - (timeoutMs) => - resumeProviderSessionToSend + async (timeoutMs, _reconcileExisting, markCreateStarted) => { + if (launchAgentToSend === 'omp') { + await assertRemoteAgentThinkingSupported({ + environmentId: createEnvironmentId, + agent: launchAgentToSend, + launchPreferences: agentLaunchPreferences + }) + } + markCreateStarted() + return resumeProviderSessionToSend ? callRuntimeForEnvironment( createEnvironmentId, 'terminal.ensureAgentSession', @@ -3176,7 +3206,8 @@ export function createRemoteRuntimePtyTransport( agentCreateOperation.clientOperationId ), timeoutMs - ), + ) + }, createEnvironmentId, connectLifecycleEpoch ) @@ -3189,6 +3220,8 @@ export function createRemoteRuntimePtyTransport( ? await hostAuthorityCreate() : await runRemoteAgentSessionLaunch({ environmentId: createEnvironmentId, + agent: launchAgentToSend, + launchPreferences: agentLaunchPreferences, hostAuthority: hostAuthorityCreate, ...(resumeHostAuthorityCapability ? { hostAuthorityCapability: resumeHostAuthorityCapability } @@ -3283,7 +3316,11 @@ export function createRemoteRuntimePtyTransport( } else if (isRecoverablePaneBindingError(error)) { scheduleConnectRetryAfterRecoverableFailure() } else { - recovery.cancel() + if (terminalCreateNeedsReconciliation || agentSessionRequiresHostAuthorityReplay) { + recovery.markDisconnected() + } else { + recovery.cancel() + } emitRecoveryState() surfaceErrorMessage(message) } diff --git a/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts b/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts index 9c0b5b304a4f..cf41d577af26 100644 --- a/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts +++ b/src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts @@ -1,5 +1,5 @@ import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { buildTerminalTabRetirementPlan, getTerminalPtyOwnershipIdentity diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index fc3f54b4d145..8265897fb2f7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -5,7 +5,7 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { extractOrchestrationTaskLinks, diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts index 330560c57a6d..9e7b163ea93a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts index 415da2858332..b3b6322cdd94 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-source-owner.ts @@ -1,8 +1,6 @@ import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' -import { - getRemoteRuntimePtyEnvironmentId, - parseRemoteRuntimePtyId -} from '@/runtime/runtime-terminal-stream' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { PtyTransport } from './pty-transport-types' import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx b/src/renderer/src/components/terminal-pane/terminal-layout-title-persist.test.tsx similarity index 70% rename from src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx rename to src/renderer/src/components/terminal-pane/terminal-layout-title-persist.test.tsx index 57b1a54bb338..175eb46875cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-gesture-persist.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-layout-title-persist.test.tsx @@ -79,8 +79,7 @@ function renderPersistence() { controller as unknown as Parameters[0] ) ) - const pushedIntents = (): unknown[] => push.mock.calls.map(([input]) => input.intent) - return { hook, push, setTabLayout, pushedIntents, panes } + return { hook, push } } afterEach(() => { @@ -89,43 +88,15 @@ afterEach(() => { mocks.sendLayoutCommandToOwner.mockReset() }) -describe('terminal layout gesture persist', () => { - it('saves the same layout for a gesture and an automatic persist and marks only the push', () => { - const { hook, push, setTabLayout } = renderPersistence() - setTabLayout.mockClear() - push.mockClear() - - hook.result.current.persistLayoutSnapshot() - hook.result.current.persistLayoutSnapshot('gesture') - - const [[, automaticLayout], [, gestureLayout]] = setTabLayout.mock.calls - expect(gestureLayout).toEqual(automaticLayout) - expect(gestureLayout).not.toHaveProperty('intent') - const [[automaticPush], [gesturePush]] = push.mock.calls - expect(automaticPush.intent).toBeUndefined() - expect(gesturePush).toEqual({ ...automaticPush, intent: 'gesture' }) - }) - - it('leaves mount and scrollback-clear persists unmarked', () => { - const { hook, pushedIntents, panes } = renderPersistence() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only id, leafId and terminal, all present. - const pane = panes[0] as unknown as Parameters< - typeof hook.result.current.clearPaneScrollback - >[0] - hook.result.current.clearPaneScrollback(pane) - - expect(pushedIntents().length).toBeGreaterThanOrEqual(2) - expect(pushedIntents().every((intent) => intent === undefined)).toBe(true) - }) - - it('marks user title clears as gestures', () => { - const { hook, push, pushedIntents } = renderPersistence() +describe('terminal layout title persist', () => { + it('pushes the cleared layout when the clear-title shortcut runs', () => { + const { hook, push } = renderPersistence() push.mockClear() hook.result.current.handleClearPaneTitleShortcut(1) - hook.result.current.removePaneTitle(2) - expect(pushedIntents()).toEqual(['gesture', 'gesture']) + expect(push).toHaveBeenCalledOnce() + expect(push.mock.lastCall?.[0].layout.titlesByLeafId).toEqual({}) }) it('tells the host to drop every title once the last remote pane title is cleared', () => { @@ -145,19 +116,19 @@ describe('terminal layout gesture persist', () => { }) it('a window-owned build saves a renamed pane at once, without asking main', () => { - const { hook, pushedIntents, push } = renderPersistence() + const { hook, push } = renderPersistence() push.mockClear() hook.result.current.changePaneTitle(2, 'server') expect(mocks.sendLayoutCommandToOwner).not.toHaveBeenCalled() - expect(pushedIntents()).toEqual(['gesture']) + expect(push).toHaveBeenCalledOnce() }) it('a layout-owner build shows a title change only once main has saved it', () => { mocks.layoutOwnerIsMain = true mocks.sendLayoutCommandToOwner.mockReturnValue(true) - const { hook, pushedIntents, push } = renderPersistence() + const { hook, push } = renderPersistence() push.mockClear() hook.result.current.changePaneTitle(2, 'server') @@ -167,11 +138,11 @@ describe('terminal layout gesture persist', () => { { type: 'renamePane', tabId: 'tab', leafId: RIGHT, title: 'server' }, { type: 'renamePane', tabId: 'tab', leafId: LEFT, title: null } ]) - expect(pushedIntents()).toEqual([]) + expect(push).not.toHaveBeenCalled() for (const [, , handlers] of mocks.sendLayoutCommandToOwner.mock.calls) { handlers.apply({}) } - expect(pushedIntents()).toEqual(['gesture', 'gesture']) + expect(push).toHaveBeenCalledTimes(2) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts index 2dc8ce69a000..516103cb205b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts @@ -5,7 +5,7 @@ import { } from '@/store/slices/terminal-tab-retirement' import { locateTerminalTab } from '@/store/terminals/terminal-tab-location' import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' import type { TerminalPaneBindingController } from './use-terminal-pane-layout-bindings' diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts index 7f66070a2fe8..08706169f468 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts @@ -18,7 +18,6 @@ import type { ReplayingPanesRef } from './replay-guard' import type { TerminalLinkActionRequester } from './terminal-link-action-request' import type { TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' export type TerminalPaneStartup = Exclude @@ -102,7 +101,7 @@ export type UseTerminalPaneLifecycleDeps = { setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setExpandedPane: (paneId: number | null) => void syncExpandedLayout: () => void - persistLayoutSnapshot: (intent?: PaneLayoutEditIntent) => void + persistLayoutSnapshot: () => void showPaneTitle: (paneId: number, title: string | null) => void setPaneTitles: React.Dispatch>> paneTitlesRef: React.RefObject> diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options-gesture.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options-persist.test.ts similarity index 71% rename from src/renderer/src/components/terminal-pane/terminal-pane-manager-options-gesture.test.ts rename to src/renderer/src/components/terminal-pane/terminal-pane-manager-options-persist.test.ts index a8554f4f87d1..2f21accbd68a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options-gesture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options-persist.test.ts @@ -28,22 +28,16 @@ function createOptions(shouldPersistLayout = true) { return { options, persistLayoutSnapshot } } -describe('terminal pane manager layout gesture wiring', () => { - it('persists a gesture with the gesture marker', () => { - const { options, persistLayoutSnapshot } = createOptions() - options.onLayoutChanged?.('gesture') - expect(persistLayoutSnapshot).toHaveBeenCalledExactlyOnceWith('gesture') - }) - - it('persists an automatic layout change unmarked, as before', () => { +describe('terminal pane manager layout persist wiring', () => { + it('persists a layout change', () => { const { options, persistLayoutSnapshot } = createOptions() options.onLayoutChanged?.() - expect(persistLayoutSnapshot).toHaveBeenCalledExactlyOnceWith(undefined) + expect(persistLayoutSnapshot).toHaveBeenCalledOnce() }) - it('keeps the persist gate for gestures', () => { + it('keeps the persist gate', () => { const { options, persistLayoutSnapshot } = createOptions(false) - options.onLayoutChanged?.('gesture') + options.onLayoutChanged?.() expect(persistLayoutSnapshot).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts index 462145dcc657..350bf4025a23 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts @@ -85,7 +85,7 @@ export function createTerminalPaneManagerOptions( context.deps.updateTabTitle(tabId, paneTitle) } }, - onLayoutChanged: (intent) => { + onLayoutChanged: () => { scheduleRuntimeGraphSync() context.deps.syncExpandedLayout() context.syncCanExpandState() @@ -93,7 +93,7 @@ export function createTerminalPaneManagerOptions( context.syncPaneLayoutRevision() context.queueResizeAll(false) if (context.shouldPersistLayout()) { - context.deps.persistLayoutSnapshot(intent) + context.deps.persistLayoutSnapshot() } }, onPaneDragActiveChange: (active) => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index d255531bd2b0..e02f5a403947 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -12,7 +12,6 @@ import { sanitizeTerminalLayoutPaneTitles } from '../../../../shared/terminal-pane-title-sanitization' import type { TerminalPaneStartupController } from './use-terminal-pane-startup-actions' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import { notePendingPaneTitleRename } from '@/runtime/pending-pane-title-renames' @@ -43,7 +42,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup chatOwnerRef.current = { chatLeafId, isChatViewMode } }, [chatLeafId, isChatViewMode]) const persistLayoutSnapshot = useCallback( - (intent?: PaneLayoutEditIntent): void => { + (): void => { const manager = managerRef.current const container = containerRef.current if (!manager || !container) { @@ -138,8 +137,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup remotePaneLayoutPusherRef.current?.push({ worktreeId, tabId, - layout: clearedEveryTitle ? { ...layout, titlesByLeafId: {} } : layout, - intent + layout: clearedEveryTitle ? { ...layout, titlesByLeafId: {} } : layout }) } for (const leafId of currentLeafIds) { @@ -189,7 +187,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup removedTitleLeafIdsRef.current.add(leafId) } // Why: every caller is a user clearing a pane title. - persistLayoutSnapshot('gesture') + persistLayoutSnapshot() }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. [persistLayoutSnapshot] @@ -202,7 +200,7 @@ export function useTerminalPaneLayoutPersistence(controller: TerminalPaneStartup if (leafId) { removedTitleLeafIdsRef.current.delete(leafId) } - persistLayoutSnapshot('gesture') + persistLayoutSnapshot() }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Refs are stable. [persistLayoutSnapshot] diff --git a/src/renderer/src/components/terminal-workspace-model.ts b/src/renderer/src/components/terminal-workspace-model.ts index 44e732fe0da1..360ae69ced82 100644 --- a/src/renderer/src/components/terminal-workspace-model.ts +++ b/src/renderer/src/components/terminal-workspace-model.ts @@ -1,7 +1,7 @@ import type { Tab } from '../../../shared/tab-types' import type { KeybindingContext } from '../../../shared/keybindings' import { useAppStore } from '../store' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type' export const CLOSE_DIALOG_DEBOUNCE_MS = 200 @@ -56,7 +56,7 @@ export function isPinnedVisibleTab( } export function getActiveWorktreeRuntimeEnvironmentId(worktreeId: string | null): string | null { - return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) + return runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(useAppStore.getState(), worktreeId) } export function isPinnedActiveEditorTab( diff --git a/src/renderer/src/components/terminal/terminal-close-incarnation.ts b/src/renderer/src/components/terminal/terminal-close-incarnation.ts index 013712884d5a..79e9f2c34c6a 100644 --- a/src/renderer/src/components/terminal/terminal-close-incarnation.ts +++ b/src/renderer/src/components/terminal/terminal-close-incarnation.ts @@ -1,4 +1,4 @@ -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' export function getTerminalIncarnationHandle(ptyId: string, environmentId: string): string | null { const terminal = parseRemoteRuntimePtyId(ptyId) if (terminal?.handle && terminal.environmentId === environmentId) { diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index b19a1c769dc3..2763406ee92d 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -1,5 +1,6 @@ 'use client' +import { SECURITY_SURFACE_CLASS } from './security-surface' import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { Minus, XIcon } from 'lucide-react' @@ -54,7 +55,9 @@ const dialogContentSurfaceVariants = cva('', { 'feature-tip': 'gap-0 bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))]', /** Edge to edge, for an embedded panel that brings its own header and padding. */ - panel: 'gap-0 p-0' + panel: 'gap-0 p-0', + /** Trust, consent and provenance decisions: host-owned tokens a plugin theme cannot restyle. */ + security: SECURITY_SURFACE_CLASS } }, defaultVariants: { surface: 'default' } @@ -94,6 +97,7 @@ function DialogContent({ closeButtonVariant = 'close', surface, size, + scrollable = false, ...props }: React.ComponentProps & VariantProps & { @@ -102,6 +106,8 @@ function DialogContent({ closeButtonVariant?: 'close' | 'minimize' /** `compact` is a short confirmation's width, like the close prompts. */ size?: 'default' | 'compact' + /** The whole dialog scrolls within the viewport instead of growing past it. */ + scrollable?: boolean }) { return ( @@ -120,6 +126,7 @@ function DialogContent({ className={cn( 'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] grid-cols-[minmax(0,1fr)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black/14 bg-background/96 p-6 text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl duration-200 outline-none dark:border-white/14 dark:bg-[rgba(23,23,23,0.96)] dark:shadow-[0_24px_72px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.06)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg data-[size=compact]:sm:max-w-sm', dialogContentSurfaceVariants({ surface }), + scrollable && 'max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek', className )} {...props} diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 1fe367c360e7..4e9dbe0846e4 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -54,7 +54,8 @@ function DropdownMenuItem({ ...props }: React.ComponentProps & { inset?: boolean - variant?: 'default' | 'destructive' + /** `launch`: the roomier rows of the tab bar's new tab menu. */ + variant?: 'default' | 'destructive' | 'launch' }) { return ( { + it('applies the host-owned token set', () => { + render( + + + Trust + Review + + + ) + expect(document.querySelector('[role="dialog"]')?.classList).toContain('plugin-security-chrome') + }) + + it.each(TRUST_DIALOGS)('%s renders on the security surface', (file) => { + const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8') + expect(source).toMatch(/]*surface="security"/) + expect(source).not.toContain('plugin-security-chrome') + }) + + it('applies the host-owned token set outside dialogs', () => { + const { container } = render( + +
Allow
+
+ ) + expect(container.firstElementChild?.classList).toContain('plugin-security-chrome') + }) + + it.each(INLINE_TRUST_SURFACES)('%s renders inside a security surface', (file) => { + const source = readFileSync(join(import.meta.dirname, '..', file), 'utf8') + expect(source).toMatch(/]/) + }) +}) diff --git a/src/renderer/src/components/ui/security-surface.tsx b/src/renderer/src/components/ui/security-surface.tsx new file mode 100644 index 000000000000..ccf2a741e099 --- /dev/null +++ b/src/renderer/src/components/ui/security-surface.tsx @@ -0,0 +1,15 @@ +import type React from 'react' +import * as Slot from 'radix-ui/slot' + +/** Host-owned tokens a plugin theme cannot restyle; see `.plugin-security-chrome` in main.css. */ +export const SECURITY_SURFACE_CLASS = 'plugin-security-chrome' + +/** Puts its one child element on the host-owned token surface. For trust decisions outside a + * dialog (an agent approval, a certificate override); dialogs use `surface="security"`. */ +export function SecuritySurface({ children }: { children: React.ReactElement }): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index da8273122d52..d8522863c4aa 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react' +import { AlertTriangle, Loader2, RotateCcw } from 'lucide-react' import { useAppStore } from '@/store' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' @@ -9,6 +9,7 @@ import { } from '@/lib/pending-worktree-creation' import { Progress } from '@/components/ui/progress' import { translate } from '@/i18n/i18n' +import WorktreeCreationTabStrip from './WorktreeCreationTabStrip' /** * In-frame creation state, shown in the workspace content area while a worktree @@ -56,49 +57,12 @@ export default function WorktreeCreationPanel({ return (
- {/* Faux tab strip: mirrors the real tab row (height, border, bg) so the - create reads as a workspace tab. Carries only the worktree name + a - cancel control โ€” the live status lives in the body below. */} -
- {reserveCollapsedSidebarHeaderSpace ? ( - // Why: collapsed sidebar chrome floats above this strip, so reserve - // the same measured width real tabs use to keep title/cancel clear. -
- ) : null} -
- {isError ? ( - - ) : ( - // Why: a static worktree glyph (not a spinner) keeps the tab reading - // as a normal tab; the single loading spinner lives in the body. - - )} - {title} - -
-
+ {/* Body: a quiet top-left annotation on the surface the terminal will fill โ€” the same spot terminal output appears โ€” so creation โ†’ terminal diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationTabStrip.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationTabStrip.tsx new file mode 100644 index 000000000000..4b46606a04ba --- /dev/null +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationTabStrip.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { AlertTriangle, GitBranch, X } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +/** + * Faux tab strip: mirrors the real tab row (height, border, bg) so the create reads as a + * workspace tab. Carries only the worktree name and a cancel control; the live status lives in + * the body below. + */ +export default function WorktreeCreationTabStrip({ + title, + isError, + reserveCollapsedSidebarHeaderSpace, + onCancel +}: { + title: string + isError: boolean + reserveCollapsedSidebarHeaderSpace: boolean + onCancel: () => void +}): React.JSX.Element { + return ( +
+ {reserveCollapsedSidebarHeaderSpace ? ( + // Why: collapsed sidebar chrome floats above this strip, so reserve + // the same measured width real tabs use to keep title/cancel clear. +
+ ) : null} +
+ {isError ? ( + + ) : ( + // Why: a static worktree glyph (not a spinner) keeps the tab reading + // as a normal tab; the single loading spinner lives in the body. + + )} + {title} + +
+
+ ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts index 2355ca57d6c1..a84bb1c3db75 100644 --- a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -11,7 +11,8 @@ import { acquireBrowserAutomationBootstrapLease } from './browser-automation-boo import { beginAgentInputFocusGuard, clearAgentInputFocusGuards, - endAgentInputFocusGuard + endAgentInputFocusGuard, + watchUserComposition } from '@/components/browser-pane/host-guest/agent-input-focus-guard' /** @@ -104,16 +105,18 @@ export function registerBrowserStateIpcBridge( const unsubscribeAgentInputFocusGuard = window.api.browser.onAgentInputFocusGuard?.( ({ browserPageId, token, active }) => { if (active) { - beginAgentInputFocusGuard(browserPageId, token) - window.api.browser.agentInputFocusGuardReady(token) + const answer = beginAgentInputFocusGuard(browserPageId, token) + window.api.browser.agentInputFocusGuardReady(token, answer === 'composing') } else { endAgentInputFocusGuard(token) } } ) if (unsubscribeAgentInputFocusGuard) { + const unwatchUserComposition = watchUserComposition() unsubs.push(() => { unsubscribeAgentInputFocusGuard() + unwatchUserComposition() clearAgentInputFocusGuards() }) } diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 3ce59b736130..1908c357381a 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -10,7 +10,7 @@ import { refreshRuntimeEnvironmentSshTargetMetadata } from '@/runtime/runtime-environment-ssh-state' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { getRepoExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 369f5cadb818..f80ae7a4b6cf 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -161,7 +161,15 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toBe(true) const activeSubscriptionCount = storeSubscriptions.filter((item) => item.active).length expect(activeSubscriptionCount).toBeGreaterThan(0) - expect(addDocumentListener.mock.calls.map(([type]) => type)).toEqual(['focusin', 'pointerdown']) + const documentListenerTypes = [ + 'focusin', + 'pointerdown', + 'compositionstart', + 'compositionupdate', + 'compositionend', + 'input' + ] + expect(addDocumentListener.mock.calls.map(([type]) => type)).toEqual(documentListenerTypes) firstCleanup() expect(runtimeSubscriptions.every((unsubscribe) => unsubscribe.mock.calls.length === 1)).toBe( @@ -185,10 +193,8 @@ describe('useIpcEvents App-lifetime lifecycle', () => { ).toBe(true) expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(activeSubscriptionCount) expect(addDocumentListener.mock.calls.map(([type]) => type)).toEqual([ - 'focusin', - 'pointerdown', - 'focusin', - 'pointerdown' + ...documentListenerTypes, + ...documentListenerTypes ]) secondCleanup() diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx index 162e2ab8d233..1f127910f822 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.test.tsx @@ -68,7 +68,7 @@ vi.mock('@/store', () => ({ vi.mock('@/store/plugin-language-packs', () => ({ usePluginLanguagePackStore: (selector: (s: Record) => unknown) => - selector({ packs: [], loaded: true }) + selector({ entries: [], loaded: true }) })) vi.mock('@/i18n/i18n', () => ({ diff --git a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts index f278616a5e4c..ca4999389faa 100644 --- a/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts +++ b/src/renderer/src/hooks/useMacTccAttributionSeveredNotice.ts @@ -26,7 +26,7 @@ export function useMacTccAttributionSeveredNotice(): void { const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery) const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null) - const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs) + const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries) const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded) const { i18n } = useTranslation() const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx index 47becbaa7e06..36f757508651 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -89,7 +89,7 @@ it('isolates plugin language-pack discovery from its parent render path', async root?.render(createElement(I18nextProvider, { i18n }, createElement(ParentProbe))) }) await act(async () => { - usePluginLanguagePackStore.setState({ packs: [], loaded: true }) + usePluginLanguagePackStore.setState({ entries: [], loaded: true }) }) expect(parentRenderCount).toBe(1) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts index e62464a0b6f9..087bb1dbb972 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.ts +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -20,7 +20,7 @@ export function useMacosTccPromptNotice(): void { const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const uiLanguage = useAppStore((s) => s.settings?.uiLanguage ?? null) - const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.packs) + const pluginLanguagePacks = usePluginLanguagePackStore((s) => s.entries) const pluginLanguagePacksLoaded = usePluginLanguagePackStore((s) => s.loaded) const { i18n } = useTranslation() const selectedPluginLanguage = pluginLanguagePacks.find((pack) => pack.id === uiLanguage) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a9739ece4999..18b1e0c439db 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2458,12 +2458,18 @@ "e3ff145b98": "Split Left", "f7c3d7d5af": "Split Right" }, + "QuickLaunchButton": { + "chatLoading": "Checking chat supportโ€ฆ" + }, "SortableTabContextMenu": { "0ce4bae39d": "Split Left", "21132389e9": "Split Right", "591f9b12c1": "Split Up", "af80ed83c1": "Split Down" }, + "TabBar": { + "ab589350e5": "Could not build launch command for {{value0}}." + }, "TabBarQuickCommandsButton": { "c781f992e4": "destructive" }, @@ -3014,6 +3020,8 @@ "unavailable": "Selected answer unavailable" }, "resumeInNewChat": { + "exitUnconfirmedFromTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", + "exitUnconfirmed": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "cannotResume": "This {{agent}} conversation can't be resumed in a native chat." }, "state": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8bf09ce31dc5..c948413aedf9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3762,7 +3762,17 @@ "e518f544b1": "No agents detected", "8dea9b5cdf": "No enabled agents", "needsServerUpdate": "Update Orca on this server to list this workspaceโ€™s agents", - "hostUnreachable": "Canโ€™t reach this workspaceโ€™s host to list its agents" + "hostUnreachable": "Canโ€™t reach this workspaceโ€™s host to list its agents", + "chatNotInstalled": "Not installed", + "chatNotOverSsh": "No chat over SSH", + "chatNotInWsl": "No chat in WSL", + "chatNotOnHost": "No chat on this host", + "chatCheckFailed": "Couldnโ€™t check chat availability", + "chatLoading": "Checking chat supportโ€ฆ", + "chatUnavailableAfterSelection": "Couldn't start a {{value0}} chat.", + "startChat": "Start a chat with {{value0}}", + "cliTag": "(CLI)", + "agentsLoading": "Loading agents" }, "RecentTabSwitcher": { "329638ff6f": "Switch Tab", @@ -6353,7 +6363,10 @@ "host": { "unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again." }, - "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection." + "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection.", + "chat": { + "partial": "The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again." + } } } }, @@ -12806,7 +12819,8 @@ "behindRolledBack": "Runs an older managed Orca server than this app; the last update was rolled back. Use Update server to try again.", "behindNoTemplate": "Runs a managed Orca server this app canโ€™t update; this build has no server for this host.", "behindMigrating": "Runs a managed Orca server that updates after its project move finishes.", - "behindCheckFailed": "Runs a managed Orca server; Orca couldnโ€™t check whether it is behind this app." + "behindCheckFailed": "Runs a managed Orca server; Orca couldnโ€™t check whether it is behind this app.", + "runtimeNoexec": "This host doesnโ€™t allow running programs from ~/.orca-remote, so Orca canโ€™t start its server there. This usually means the home folder is mounted with noexec. Ask the hostโ€™s administrator to allow running programs there, then connect again." }, "ZcodeCliSetupSection": { "title": "Use GLM with ZCode", @@ -18205,7 +18219,8 @@ "reconnectTimeout": "The server did not reconnect on the updated version." }, "webRuntimeSession": { - "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host.", + "terminalCreateUnconfirmed": "Orca couldnโ€™t confirm that the new terminal opened on the host." }, "gitlabJobTraceClient": { "loadFailed": "Failed to load the GitLab job log.", @@ -18801,6 +18816,8 @@ "showTerminal": "Show terminal" }, "resumeInNewChat": { + "exitUnconfirmedFromTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", + "exitUnconfirmed": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "notSavedYet": "{{agent}} hasn't saved this conversation yet. Try again after it replies.", "cannotResume": "This {{agent}} conversation can't be resumed in a native chat." }, @@ -19014,6 +19031,9 @@ }, "thought": "Thought", "thoughtForDuration": "Thought for {{duration}}", + "explicitChatWsl": "Chat is not available in WSL. Open the agent in a terminal instead.", + "explicitChatSsh": "Chat is not available over SSH. Open the agent in a terminal instead.", + "explicitChatUnavailable": "This workspace cannot run the chat. Check the agent installation or open it in a terminal.", "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 7fa19bc1762d..f645c4d8b2c2 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -10,7 +10,7 @@ import { wrapTerminalBracketedPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' // Why: bracketed paste markers let supported TUIs treat generated prompt text diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index 336f4a41ad9f..bad73cfd6ad7 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -6,6 +6,7 @@ import { } from '@/components/terminal-pane/pty-pre-handler-buffer' import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../../shared/opencode-agent-row-scanner' import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import type * as RemoteRuntimePtyIdModule from '../../../shared/remote-runtime-pty-id' const testState = vi.hoisted(() => ({ observer: null as ((data: string) => void) | null, @@ -19,7 +20,8 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ } })) -vi.mock('@/runtime/runtime-terminal-inspection', () => ({ +vi.mock('../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), isRemoteRuntimePtyId: () => false })) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index d96b01a06b67..60bf063cb046 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -1,7 +1,7 @@ import type { DraftPasteReadySignal } from '../../../shared/tui-agent-config' import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar-subscriptions' import { replayPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' import { createDraftPasteReadyScanner } from '../../../shared/draft-paste-ready-scanner' diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts index a24486379222..997763edbacc 100644 --- a/src/renderer/src/lib/agent-followup-delivery.test.ts +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery' -import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - inspectRuntimeTerminalProcess: vi.fn(), + inspectRuntimeTerminalProcess: vi.fn() +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: vi.fn() })) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index efa2287913ab..5ff43db71ddb 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -1,7 +1,5 @@ -import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index c2f0996d04b6..b56ba6a1383c 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -22,7 +22,7 @@ import { observeHibernationPtyBindings } from './agent-hibernation-pane-age' import { mergePendingTerminalInputActivity } from './terminal-input-activity-coalescing' -import { resolveRuntimeOwnerForWorktree } from './worktree-runtime-owner' +import { resolveTerminalPtyOwner } from './terminal-worktree-route' import { toAgentHibernationRuntimeOwner } from './agent-hibernation-runtime-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -111,7 +111,7 @@ function snapshotFromState( function resolveAgentHibernationRuntimeOwner(state: AppState, worktreeId: string) { const tabs = state.tabsByWorktree[worktreeId] ?? [] const ptyIds = tabs.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) - return toAgentHibernationRuntimeOwner(resolveRuntimeOwnerForWorktree(state, worktreeId), ptyIds) + return toAgentHibernationRuntimeOwner(resolveTerminalPtyOwner(state, worktreeId), ptyIds) } /** Worktree โ†’ owning runtime; `null` means liveness is required but no host can be asked. */ diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 4660e7d3af12..d509b0bfa18e 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -2,7 +2,7 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { getAgentResumeArgv, isResumableTuiAgent } from '../../../shared/agent-session-resume' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' import { isLiveResumeAnchorForCompletedAgent } from '../../../shared/live-resume-anchor-record' import { agentTurnEndedUncleanly } from '../../../shared/agent-main-agent-verdict' diff --git a/src/renderer/src/lib/agent-hibernation-runtime-owner.ts b/src/renderer/src/lib/agent-hibernation-runtime-owner.ts index d6bb16f4aee1..f4989f76332e 100644 --- a/src/renderer/src/lib/agent-hibernation-runtime-owner.ts +++ b/src/renderer/src/lib/agent-hibernation-runtime-owner.ts @@ -1,5 +1,5 @@ import { resolveTerminalTeardownOwner } from '@/store/terminals/terminal-teardown-owner' -import type { WorktreeRuntimeOwner } from './worktree-runtime-owner' +import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route' export type AgentHibernationRuntimeOwner = | { kind: 'runtime'; environmentId: string } @@ -9,7 +9,7 @@ export type AgentHibernationRuntimeOwner = /** `ptyIds` are the worktree's bound PTYs; they only decide the owner when two hosts publish it. */ export function toAgentHibernationRuntimeOwner( - worktreeOwner: WorktreeRuntimeOwner, + worktreeOwner: TerminalPtyOwner, ptyIds: readonly string[] ): AgentHibernationRuntimeOwner { if (worktreeOwner.kind !== 'ambiguous') { diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 9c5c3db494be..42165695d22e 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -43,7 +43,8 @@ export function structuredAgentLaunchSupported( return structuredAgentLaunchSupport(input).supported } -function structuredAgentLaunchSupport(input: Omit) { +/** Whether the target host could open this agent as a chat, and why not; the settings aside. */ +export function structuredAgentLaunchSupport(input: Omit) { return resolveStructuredNativeChatSupport({ agent: input.agent, executionHostId: input.executionHostId, diff --git a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts index 82a45c4dc24b..0817abb17307 100644 --- a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts @@ -31,7 +31,9 @@ vi.mock('./agent-draft-readiness', () => ({ })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - inspectRuntimeTerminalProcess: testState.inspectProcess, + inspectRuntimeTerminalProcess: testState.inspectProcess +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: testState.sendInput })) diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index a8dee509d05e..6360b1b19ef2 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -6,6 +6,7 @@ import { sendAgentDraftPasteContent } from './agent-paste-draft' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import type * as RemoteRuntimePtyIdModule from '../../../shared/remote-runtime-pty-id' const testState = vi.hoisted(() => ({ appState: { @@ -42,10 +43,15 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, - sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess })) +vi.mock('../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ + sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified +})) vi.mock('@/runtime/runtime-terminal-stream', () => ({ subscribeToRuntimeTerminalData: testState.subscribeToRuntimeTerminalData diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 3a6be024c3ea..9ffae28c7beb 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -13,6 +13,7 @@ import { submitPromptToAgentPty } from './agent-paste-draft' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import type * as RemoteRuntimePtyIdModule from '../../../shared/remote-runtime-pty-id' const testState = vi.hoisted(() => ({ appState: { @@ -55,11 +56,16 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess +})) +vi.mock('../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. sendRuntimePtyInputVerified: (ptyId: string, data: string) => - testState.sendRuntimePtyInputVerified(ptyId, data), - inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess + testState.sendRuntimePtyInputVerified(ptyId, data) })) vi.mock('@/runtime/runtime-terminal-stream', () => ({ diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 516826acbd4e..6b95ac716530 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -5,10 +5,8 @@ import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS } from '../../../shared/agent-p import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { resolvePasteReadySignal } from '../../../shared/draft-paste-ready-scanner' import { useAppStore } from '@/store' -import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 94be3a862f7f..757728344f67 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -37,6 +37,8 @@ import { awaitLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilit export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { /** The user action this launch serves, minted where that action is handled. */ requestId: AgentLaunchRequestId + requestedSurface?: 'chat' | 'terminal' + selectedExecutionHostId?: ExecutionHostId resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void /** The caller keeps the prompt's text if it does not go out (notes), so no composer gets it. */ @@ -50,6 +52,7 @@ export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { */ export type AgentSessionLaunchVerdict = { route: AgentLaunchRoute + requestedSurface?: 'chat' | 'terminal' /** The user action this launch serves; a re-entry with this verdict is that same action. */ requestId: AgentLaunchRequestId agent: TuiAgent @@ -283,12 +286,21 @@ export function planAgentSessionLaunch( request: AgentSessionLaunchRequest ): AgentSessionLaunchPlan { const input = buildAgentLaunchRouteInput(store, request) - const route = resolveAgentLaunchRoute(input) - reportStructuredLaunchDowngrade(store, input, route) + const route = request.requestedSurface + ? request.requestedSurface === 'chat' + ? 'structured-native-chat' + : 'terminal-tui' + : resolveAgentLaunchRoute(input) + if (!request.requestedSurface) { + reportStructuredLaunchDowngrade(store, input, route) + } const executionHostId = - route === 'structured-native-chat' ? parseExecutionHostId(input.executionHostId)?.id : undefined + route === 'structured-native-chat' + ? (request.selectedExecutionHostId ?? parseExecutionHostId(input.executionHostId)?.id) + : undefined return adoptAgentSessionLaunchVerdict({ route, + ...(request.requestedSurface ? { requestedSurface: request.requestedSurface } : {}), requestId: request.requestId, agent: request.agent, ...(executionHostId ? { executionHostId } : {}), diff --git a/src/renderer/src/lib/agent-status-connection-ownership.ts b/src/renderer/src/lib/agent-status-connection-ownership.ts index e5b6475cdc03..304826bb7548 100644 --- a/src/renderer/src/lib/agent-status-connection-ownership.ts +++ b/src/renderer/src/lib/agent-status-connection-ownership.ts @@ -1,7 +1,9 @@ -import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../shared/remote-runtime-pty-id' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' export type AgentStatusConnectionRouting = { connectionId: string | null } diff --git a/src/renderer/src/lib/chat-provider-agents.ts b/src/renderer/src/lib/chat-provider-agents.ts new file mode 100644 index 000000000000..f177170e7e38 --- /dev/null +++ b/src/renderer/src/lib/chat-provider-agents.ts @@ -0,0 +1,34 @@ +import { useSyncExternalStore } from 'react' +import type { TuiAgent } from '../../../shared/tui-agent' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import { + hostStructuredAgentsVersion, + readHostStructuredAgents, + subscribeHostStructuredAgents, + type HostStructuredAgentsStatuses +} from '@/runtime/host-structured-agents' + +const BUILT_IN_CHAT_AGENTS: readonly TuiAgent[] = ['claude', 'codex'] + +/** Claude and Codex, which every chat host runs, then the other agents a host registered for chat. */ +export function chatProviderAgents( + executionHostId: string, + statuses: HostStructuredAgentsStatuses +): TuiAgent[] { + const registered = readHostStructuredAgents(executionHostId, statuses) ?? [] + return [ + ...new Set([ + ...BUILT_IN_CHAT_AGENTS, + ...registered.map((row) => row.agent).filter((agent) => isTuiAgent(agent)) + ]) + ] +} + +/** Re-renders the caller whenever a host's chat agent list changes. */ +export function useHostStructuredAgentsVersion(): number { + return useSyncExternalStore( + subscribeHostStructuredAgents, + hostStructuredAgentsVersion, + hostStructuredAgentsVersion + ) +} diff --git a/src/renderer/src/lib/cli-conversation-exit-guidance.ts b/src/renderer/src/lib/cli-conversation-exit-guidance.ts new file mode 100644 index 000000000000..41da962ac58f --- /dev/null +++ b/src/renderer/src/lib/cli-conversation-exit-guidance.ts @@ -0,0 +1,22 @@ +import { translate } from '@/i18n/i18n' +import { agentLabel } from '../../../shared/ai-vault-session-filters' +import type { CliConversationRowAgent } from '../../../shared/structured-agent-cli-conversation-adoption' + +/** The header action disappears once the agent exits, so that surface points to Session History. */ +export function cliConversationExitGuidance( + agent: CliConversationRowAgent, + fromTerminal = false +): string { + const values = { agent: agentLabel(agent) } + return fromTerminal + ? translate( + 'components.native-chat.resumeInNewChat.exitUnconfirmedFromTerminal', + "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then open this conversation from Session History.", + values + ) + : translate( + 'components.native-chat.resumeInNewChat.exitUnconfirmed', + "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + values + ) +} diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index a1c2520d8841..d78ba165fd50 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,6 +1,6 @@ import type { AppState } from '@/store' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { getCodexSelectionLaneKey, normalizeCodexAccountSelectionTarget, diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 5a910f040f15..b1b827bac2b6 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -6,18 +6,29 @@ import { getProjectIdentityKey } from '../../../shared/project-host-setup-projec import type { Repo } from '../../../shared/repo-types' import { translate } from '@/i18n/i18n' import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup' +import { + appendProvisioningLog, + appendProvisioningWarnings +} from '@/lib/pending-creation-provisioning-log' -const MAX_PROVISIONING_LOG_CHARS = 12_000 - +/** Provisions whatever remote target the request still needs before the worktree is created. */ export async function prepareRequestForCreate( creationId: string, request: WorktreeCreationRequest ): Promise { - if (!request.ephemeralVmRecipe || request.ephemeralVmRuntimeId) { - return request + if (request.ephemeralVmRecipe && !request.ephemeralVmRuntimeId) { + return prepareEphemeralVmRecipeRequest(creationId, request, request.ephemeralVmRecipe) } + return request +} + +async function prepareEphemeralVmRecipeRequest( + creationId: string, + request: WorktreeCreationRequest, + recipe: NonNullable +): Promise { const store = useAppStore.getState() - if (request.ephemeralVmRecipe.checkoutMode === 'provisioned-root' && request.sparseCheckout) { + if (recipe.checkoutMode === 'provisioned-root' && request.sparseCheckout) { store.updatePendingWorktreeCreation(creationId, { status: 'error', error: getProvisionedRootSparseCheckoutError() @@ -36,16 +47,13 @@ export async function prepareRequestForCreate( }) let preparedTarget: Awaited> try { - const sourceRepo = store.repos.find( - (repo) => repo.id === request.ephemeralVmRecipe?.sourceRepoId - ) + const sourceRepo = store.repos.find((repo) => repo.id === recipe.sourceRepoId) preparedTarget = await prepareEphemeralVmWorkspaceTarget({ - repoId: request.ephemeralVmRecipe.sourceRepoId, - recipeId: request.ephemeralVmRecipe.recipeId, - projectId: - resolvePortableEphemeralVmProjectId(sourceRepo) ?? request.ephemeralVmRecipe.projectId, + repoId: recipe.sourceRepoId, + recipeId: recipe.recipeId, + projectId: resolvePortableEphemeralVmProjectId(sourceRepo) ?? recipe.projectId, workspaceName: request.name, - ...(request.ephemeralVmRecipe.checkoutMode === 'provisioned-root' + ...(recipe.checkoutMode === 'provisioned-root' ? { branch: request.branchNameOverride ?? request.name, ...(request.baseBranch ? { ref: request.baseBranch } : {}) @@ -138,35 +146,6 @@ function getEphemeralVmPortableBaseSelection( return { baseBranch: undefined, compareBaseRef: undefined } } -function appendProvisioningWarnings( - creationId: string, - warnings: readonly { message: string; remediation?: string }[] -): void { - if (warnings.length === 0) { - return - } - const text = warnings - .map((warning) => - warning.remediation - ? `Warning: ${warning.message}\n${warning.remediation}\n` - : `Warning: ${warning.message}\n` - ) - .join('') - appendProvisioningLog(creationId, text) -} - -function appendProvisioningLog(creationId: string, chunk: string): void { - const store = useAppStore.getState() - const entry = store.pendingWorktreeCreations[creationId] - if (!entry) { - return - } - // Why: recipe stdout contains the structured result with pairing credentials; - // only stderr is displayed, and the in-memory tail is bounded. - const nextLog = `${entry.provisioningLog ?? ''}${chunk}`.slice(-MAX_PROVISIONING_LOG_CHARS) - store.updatePendingWorktreeCreation(creationId, { provisioningLog: nextLog }) -} - export async function attachEphemeralVmRuntimeToWorkspace( request: WorktreeCreationRequest, workspaceId: string diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 29b67910fa1a..0aa5ab3c1595 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -22,10 +22,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner' import { retireProvider } from '@/lib/retire-unowned-background-terminal' import { createRuntimeAgentBackgroundTerminal } from '@/lib/runtime-agent-background-create' -import { - subscribeToRuntimeTerminalData, - toRemoteRuntimePtyId -} from '@/runtime/runtime-terminal-stream' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { hasExtraAgentArgs } from '../../../shared/automation-extra-agent-args' import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts index 40cd64daa98e..b93e8780a636 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -31,7 +31,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRunti vi.mock('@/lib/structured-agent-launch-settlement', () => ({})) import * as localCapabilities from '@/runtime/local-runtime-capabilities' -import { resetHostStructuredAgentsForTests } from '@/runtime/host-structured-agents' +import { + resetHostStructuredAgentsForTests, + loadHostStructuredAgents +} from '@/runtime/host-structured-agents' import { refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests @@ -270,3 +273,20 @@ describe("a new tab during startup, before this computer's runtime answered", () ) }) }) + +it('keeps a selected CLI terminal when native discovery and the catalog later allow chat', async () => { + const store = storeWithSettings(SETTINGS) + mocks.state.current.localDetectedAgentIdsByContext = { host: [] } + setLocalRuntimeCapabilitiesForTests(null) + const route = routeNewTabLaunch(store, args, { ...request, requestedSurface: 'terminal' }) + if (!('plan' in route)) { + throw new Error('explicit surface must not wait') + } + mocks.state.current.localDetectedAgentIdsByContext = { host: ['opencode'] } + setLocalRuntimeCapabilitiesForTests(REGISTERED) + await loadHostStructuredAgents('local', REGISTERED, null) + expect(route.plan?.route).toBe('terminal-tui') + expect(routeNewTabLaunch(store, args, request)).toMatchObject({ + plan: { route: 'structured-native-chat' } + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts index 9b24abef455e..0c5b888b510a 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -34,7 +34,9 @@ export function routeNewTabLaunch( if (requestId === undefined) { return { plan: args.agentSessionLaunchPlan } } - const awaited = awaitStructuredRouteHostAnswer(store, request, HOST_ANSWER_WAIT_MS) + const awaited = request.requestedSurface + ? null + : awaitStructuredRouteHostAnswer(store, request, HOST_ANSWER_WAIT_MS) return awaited ? { awaited, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index 903c7d51d13b..e0e201953f0d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -98,15 +98,7 @@ describe('launchAgentInNewTab paired web runtime', () => { pasteDraftAfterLaunch: false }) ) - expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentSessionKind: 'fresh', - agent: 'claude', - viewMode: 'terminal' - }) + expect(mocks.createTab).not.toHaveBeenCalled() await Promise.resolve() // Why: host creation is async, so the user may be viewing another worktree by the time it lands. @@ -114,45 +106,4 @@ describe('launchAgentInNewTab paired web runtime', () => { // A local tab closes locally (the host close no-ops), so a launch leaves it alone. expect(mocks.closeTab).not.toHaveBeenCalled() }) - - it('forwards prompt launch env and captured config to the host runtime', async () => { - store.settings.agentDefaultArgs = { codex: '--model gpt-5 --reasoning-effort high' } - store.settings.agentDefaultEnv = { codex: { CODEX_PROFILE: 'captured' } } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - requestId: 'request-2', - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'fix the spinner', - groupId: 'group-1' - }) - - expect(result).toEqual( - expect.objectContaining({ - surface: { kind: 'host-published' }, - pasteDraftAfterLaunch: false - }) - ) - expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentSessionKind: 'fresh', - launchAgent: 'codex', - command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", - env: { CODEX_PROFILE: 'captured' }, - launchConfig: { - agentCommand: "codex '--model' 'gpt-5' '--reasoning-effort' 'high'", - agentArgs: '--model gpt-5 --reasoning-effort high', - agentEnv: { CODEX_PROFILE: 'captured' } - }, - startupCommandDelivery: 'shell-ready', - prompt: 'fix the spinner', - promptDelivery: 'auto-submit', - viewMode: 'terminal' - }) - expect(mocks.createTab).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 885be5b6ae87..3b7d4051ad04 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -544,15 +544,13 @@ describe('launchAgentInNewTab', () => { launchAgentInNewTab({ requestId: 'request-12', agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - agentSessionKind: 'fresh', - agent: 'codex', - viewMode: 'terminal' - }) - ) + expect(mockCreateWebRuntimeSessionTerminal.mock.calls[0]?.[0]).toMatchObject({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agentSessionKind: 'fresh', + agent: 'codex', + viewMode: 'terminal' + }) }) it('propagates the resolved terminal mode to paired web runtime launches', async () => { @@ -568,15 +566,13 @@ describe('launchAgentInNewTab', () => { launchAgentInNewTab({ requestId: 'request-13', agent: 'codex', worktreeId: 'wt-1' }) - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - agentSessionKind: 'fresh', - agent: 'codex', - viewMode: 'terminal' - }) - ) + expect(mockCreateWebRuntimeSessionTerminal.mock.calls[0]?.[0]).toMatchObject({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agentSessionKind: 'fresh', + agent: 'codex', + viewMode: 'terminal' + }) }) it('surfaces a toast when host agent launch fails in paired web clients', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 22ae267b4b08..f51fe6a7d25b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,3 +1,7 @@ +import type { + AgentSessionLaunchPlan, + AgentSessionLaunchRequest +} from '@/lib/agent-session-launch-plan' import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' @@ -24,7 +28,6 @@ import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-s import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' -import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { launchOnceHostAnswered, routeNewTabLaunch @@ -43,6 +46,8 @@ type LaunchAgentInNewTabRequest = } export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { + requestedSurface?: AgentSessionLaunchRequest['requestedSurface'] + selectedExecutionHostId?: AgentSessionLaunchRequest['selectedExecutionHostId'] agent: TuiAgent worktreeId: string /** Captured at a new-chat action before the host can delay route selection. */ @@ -178,6 +183,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent // server included, so only a non-structured route falls through to the host-published terminal. const route = routeNewTabLaunch(store, args, { agent, + requestedSurface: args.requestedSurface, + selectedExecutionHostId: args.selectedExecutionHostId, workspace: { kind: workspaceKind, worktreeId }, prompt: trimmedPrompt, promptDelivery: routePromptDelivery, diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts index e2907b21e70b..ee4a8f1d5c87 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RemoteRuntimePtyIdModule from '../../../shared/remote-runtime-pty-id' const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' @@ -42,8 +43,13 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ } })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - isRemoteRuntimePtyId: () => false, - inspectRuntimeTerminalProcess: vi.fn(async () => null), + inspectRuntimeTerminalProcess: vi.fn(async () => null) +})) +vi.mock('../../../shared/remote-runtime-pty-id', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteRuntimePtyId: () => false +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: async (ptyId: string, data: string) => { testState.writes.push({ at: Date.now(), ...(ptyId === 'pty-1' ? {} : { ptyId }), data }) return true diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 16fb8da79bd3..b9d7c2b29559 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -60,17 +60,14 @@ export function launchAgentInWebHostTab(args: { ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', - ...(hasPrompt - ? { - launchAgent: agent, - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}) - } - : { agent }), + command: startupPlan.launchCommand, + launchAgent: agent, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + ...(!hasPrompt ? { agent } : {}), ...(hasPrompt && pastePromptAfterReady === null ? { prompt } : {}), ...(hasPrompt && pastePromptAfterReady === null ? { promptDelivery: structuredPromptDelivery } @@ -122,7 +119,7 @@ export function launchAgentInWebHostTab(args: { launchDraft: prompt }).then((outcome) => handleCreation({ outcome, promptDelivered: outcome.status === 'created' })) } - return createWebRuntimeSessionTerminal(launch).then((outcome) => + return createWebRuntimeSessionTerminal(launch, { notifyFailure: false }).then((outcome) => handleCreation({ outcome, promptDelivered: outcome.status === 'created' && hasPrompt }) ) } diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index f51805a45260..5e8ed72b57fe 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -178,24 +178,28 @@ describe('launchAiVaultSessionInNewTab', () => { }) expect(result.tabId).toBeNull() - expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'env-1', - targetGroupId: 'group-1', - agentSessionKind: 'resume', - launchAgent: 'codex', - command: "codex resume 'session-1'", - env: { CODEX_PROFILE: 'runtime' }, - envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], - launchConfig: { - agentCommand: 'codex', + expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith( + { + worktreeId: 'wt-1', + environmentId: 'env-1', + targetGroupId: 'group-1', + agentSessionKind: 'resume', + launchAgent: 'codex', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'runtime' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'runtime' } + }, + providerSession: { key: 'session_id', id: 'session-1' }, agentArgs: '', - agentEnv: { CODEX_PROFILE: 'runtime' } + activate: true }, - providerSession: { key: 'session_id', id: 'session-1' }, - agentArgs: '', - activate: true - }) + // Its callers toast the failure with the agent's own wording. + { notifyFailure: false } + ) expect(mockCreateTab).not.toHaveBeenCalled() expect(queuedStartups()).toEqual([]) @@ -220,7 +224,8 @@ describe('launchAiVaultSessionInNewTab', () => { expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right') expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith( - expect.objectContaining({ targetGroupId: 'group-new' }) + expect.objectContaining({ targetGroupId: 'group-new' }), + { notifyFailure: false } ) if (result.tabId === null) { await result.runtimeLaunch diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index dc1e849853f8..b630483e51aa 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -44,21 +44,24 @@ export function launchAiVaultSessionInNewTab(args: { ? store.createEmptySplitGroup(args.worktreeId, split.sourceGroupId, split.splitDirection) : null const targetGroupId = splitGroupId ?? args.targetGroupId - const runtimeLaunch = createWebRuntimeSessionTerminal({ - worktreeId: args.worktreeId, - environmentId: runtimeEnvironmentId, - ...(targetGroupId ? { targetGroupId } : {}), - agentSessionKind: 'resume', - launchAgent: args.agent, - command: args.command, - ...(args.cwd ? { cwd: args.cwd } : {}), - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - ...(args.providerSession ? { providerSession: args.providerSession } : {}), - ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), - activate: true - }) + const runtimeLaunch = createWebRuntimeSessionTerminal( + { + worktreeId: args.worktreeId, + environmentId: runtimeEnvironmentId, + ...(targetGroupId ? { targetGroupId } : {}), + agentSessionKind: 'resume', + launchAgent: args.agent, + command: args.command, + ...(args.cwd ? { cwd: args.cwd } : {}), + ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.providerSession ? { providerSession: args.providerSession } : {}), + ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), + activate: true + }, + { notifyFailure: false } + ) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { if (outcome.status === 'created') { useAppStore.getState().setActiveTabType('terminal', args.worktreeId) diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index d0244000790a..0301e84876d7 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -271,8 +271,10 @@ describe('structured agent session launch', () => { kind: 'admitted' }) vi.mocked(callStructuredAgentSession).mockResolvedValueOnce({ supported: false, reason: 'wsl' }) + // The reason names what to tell the user (for example, no chat in WSL). await expect(admitStructuredLaunchOnHost(server, 'id:wt-1', 'claude')).resolves.toEqual({ - kind: 'declined' + kind: 'declined', + reason: 'wsl' }) vi.mocked(callStructuredAgentSession).mockRejectedValueOnce(new Error('runtime unreachable')) await expect(admitStructuredLaunchOnHost(server, 'id:wt-1', 'claude')).resolves.toEqual({ diff --git a/src/renderer/src/lib/runtime-owner-alias-golden.test-fixture.ts b/src/renderer/src/lib/legacy-runtime-owner-answers.test-fixture.ts similarity index 95% rename from src/renderer/src/lib/runtime-owner-alias-golden.test-fixture.ts rename to src/renderer/src/lib/legacy-runtime-owner-answers.test-fixture.ts index 1b8077a7f5d5..ae9b11b95922 100644 --- a/src/renderer/src/lib/runtime-owner-alias-golden.test-fixture.ts +++ b/src/renderer/src/lib/legacy-runtime-owner-answers.test-fixture.ts @@ -1,6 +1,6 @@ /** - * The deprecated `resolveRuntimeOwnerForWorktree` over the alias matrix; must not move. - * Captured by running main @ 37c314bcc960 over the whole matrix. + * What the deleted `resolveRuntimeOwnerForWorktree` answered over the owner matrix (main @ + * 37c314bcc960), so its replacements can list every deliberate difference. */ export const LEGACY_RUNTIME_OWNER_GOLDEN: Readonly> = { 'focused | null': null, diff --git a/src/renderer/src/lib/new-tab-agent-menu-rows.test.ts b/src/renderer/src/lib/new-tab-agent-menu-rows.test.ts new file mode 100644 index 000000000000..9f997c76834a --- /dev/null +++ b/src/renderer/src/lib/new-tab-agent-menu-rows.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import type { TuiAgent } from '../../../shared/tui-agent' +import { buildNewTabAgentMenuRows } from './new-tab-agent-menu-rows' +const disabledTuiAgents: TuiAgent[] = ['pi'] +const input = { + settings: { experimentalNativeChat: true, disabledTuiAgents }, + detectedOnHost: ['claude', 'gemini'] as const, + chatAgents: ['claude', 'codex', 'pi'] as const, + hostChatAnswer: () => 'yes' as const +} +it('orders enabled chats, unavailable providers and CLI rows', () => { + expect(buildNewTabAgentMenuRows(input)).toEqual([ + { kind: 'chat', agent: 'claude' }, + { kind: 'chat-unavailable', agent: 'codex', reason: 'not-installed' }, + { kind: 'cli', agent: 'gemini' } + ]) +}) +it.each([true, false])('uses workspace installation for unchosen agents: %s', (installed) => { + expect( + buildNewTabAgentMenuRows({ + ...input, + detectedOnHost: installed ? ['opencode'] : [], + chatAgents: ['opencode'] + }) + ).toEqual(installed ? [{ kind: 'chat', agent: 'opencode' }] : []) +}) diff --git a/src/renderer/src/lib/new-tab-agent-menu-rows.ts b/src/renderer/src/lib/new-tab-agent-menu-rows.ts new file mode 100644 index 000000000000..28c243c0161f --- /dev/null +++ b/src/renderer/src/lib/new-tab-agent-menu-rows.ts @@ -0,0 +1,77 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { isNativeChatProviderDisplayedOn } from '../../../shared/native-chat-providers' +import { + isNativeChatEnabled, + type NativeChatDefaultSettings +} from '../../../shared/structured-native-chat-launch-route' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TuiAgent } from '../../../shared/tui-agent' +import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' +import { orderTabLaunchAgents as orderAgents } from '@/components/tab-bar/tab-agent-launch-options' + +/** What the workspace's machine says about opening an agent as a chat, installation aside. */ +export type HostChatAnswer = 'yes' | 'not-yet-known' | 'ssh' | 'wsl' | 'not-on-host' | 'no-chats' + +export type ChatUnavailableReason = 'check-failed' | 'not-installed' | 'ssh' | 'wsl' | 'not-on-host' + +export type NewTabAgentMenuRow = ( + | { kind: 'chat'; agent: TuiAgent } + | { kind: 'chat-unavailable'; agent: TuiAgent; reason: ChatUnavailableReason } + | { kind: 'cli'; agent: TuiAgent } + | { kind: 'terminal'; agent: TuiAgent } +) & { executionHostId?: ExecutionHostId } + +export type NewTabAgentMenuSettings = NativeChatDefaultSettings & + Partial> + +export function buildNewTabAgentMenuRows(args: { + settings: NewTabAgentMenuSettings | null | undefined + /** The agents detected on the workspace's machine. */ + detectedOnHost: readonly TuiAgent[] | null + /** The agents Orca can open as a chat. */ + chatAgents: readonly TuiAgent[] + hostChatAnswer: (agent: TuiAgent) => HostChatAnswer +}): NewTabAgentMenuRow[] { + const { settings } = args + const defaultAgent = settings?.defaultTuiAgent + const installed = filterEnabledTuiAgents(args.detectedOnHost ?? [], settings?.disabledTuiAgents) + if (!isNativeChatEnabled(settings) || args.hostChatAnswer('claude') === 'no-chats') { + return orderAgents(defaultAgent, installed).map((agent) => ({ kind: 'terminal', agent })) + } + const runnable: TuiAgent[] = [] + const unavailable = new Map() + for (const agent of filterEnabledTuiAgents(args.chatAgents, settings?.disabledTuiAgents)) { + if (!isNativeChatProviderDisplayedOn(settings, agent, args.detectedOnHost)) { + continue + } + const answer = args.hostChatAnswer(agent) + if (answer === 'yes' && installed.includes(agent)) { + runnable.push(agent) + } else { + unavailable.set( + agent, + args.detectedOnHost === null + ? 'check-failed' + : answer === 'yes' + ? 'not-installed' + : answer === 'not-yet-known' + ? 'check-failed' + : answer === 'no-chats' + ? 'not-on-host' + : answer + ) + } + } + return [ + ...orderAgents(defaultAgent, runnable).map((agent) => ({ kind: 'chat' as const, agent })), + ...orderAgents(defaultAgent, [...unavailable.keys()]).map((agent) => ({ + kind: 'chat-unavailable' as const, + agent, + reason: unavailable.get(agent) ?? 'not-on-host' + })), + ...orderAgents( + defaultAgent, + installed.filter((agent) => !runnable.includes(agent)) + ).map((agent) => ({ kind: 'cli' as const, agent })) + ] +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 0fe6b867a9c3..8abfc1f97f88 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -73,7 +73,9 @@ vi.mock('@/store', () => ({ })) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - inspectRuntimeTerminalProcess: mockInspectRuntimeTerminalProcess, + inspectRuntimeTerminalProcess: mockInspectRuntimeTerminalProcess +})) +vi.mock('@/runtime/runtime-terminal-verified-input', () => ({ sendRuntimePtyInputVerified: mockSendRuntimePtyInputVerified })) diff --git a/src/renderer/src/lib/paired-web-client-host.test.ts b/src/renderer/src/lib/paired-web-client-host.test.ts index 2cc371aa3af8..8ccabd33fc33 100644 --- a/src/renderer/src/lib/paired-web-client-host.test.ts +++ b/src/renderer/src/lib/paired-web-client-host.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' import { resolveWorktreeOperationRoute } from './worktree-operation-route' import { resolveTerminalHostOwnership } from './terminal-worktree-route' @@ -42,7 +42,7 @@ describe('a paired web client routes every owner through its server (#9047)', () webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'local' }), webState(UNSTAMPED_LOCAL_ROW) ]) { - expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBe(ENV) + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBe(ENV) expect(resolveTerminalHostOwnership(state, WT, 'spawn')).toEqual({ kind: 'runtime', runtimeEnvironmentId: ENV @@ -64,9 +64,9 @@ describe('a paired web client routes every owner through its server (#9047)', () { id: 'folder-1', projectGroupId: 'group-1', executionHostId: 'local', connectionId: null } ] }) - expect(getRuntimeEnvironmentIdForWorktree(folderState, folderWorkspaceKey('folder-1'))).toBe( - ENV - ) + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(folderState, folderWorkspaceKey('folder-1')) + ).toBe(ENV) }) it('keeps an SSH target as the place and the server as the transport', () => { @@ -88,12 +88,12 @@ describe('a paired web client routes every owner through its server (#9047)', () ] } }) - expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBeNull() }) it('leaves a desktop window local workspace on this computer', () => { const state = webState(UNSTAMPED_LOCAL_ROW) - expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull() + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBeNull() expect(resolveTerminalHostOwnership(state, WT, 'spawn').kind).toBe('local-or-ssh') }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts index c16f4c6e96b1..e9a6271f5964 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -132,7 +132,7 @@ describe('divider pointer capture loss', () => { expect(harness.previousPane.style.flex).toBe('220 1 0%') expect(harness.nextPane.style.flex).toBe('180 1 0%') - expect(harness.onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(harness.onLayoutChanged).toHaveBeenCalledOnce() }) it('still restores the original layout when the window loses focus', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 7a8a2c53e889..c499a215795a 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -1,9 +1,8 @@ import { holdPtyResizesForPaneSubtrees } from './pane-pty-resize-hold' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' export type DividerCallbacks = { refitPanesUnder: (el: HTMLElement) => void - onLayoutChanged?: (intent?: PaneLayoutEditIntent) => void + onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void /** A user's divider drag or reset ended; runs after `onLayoutChanged`. */ onDividerRatiosCommitted?: () => void @@ -178,7 +177,7 @@ export function attachDividerDrag( nextInitialFlex = '' if (didMove && commitLayout) { - callbacks.onLayoutChanged?.('gesture') + callbacks.onLayoutChanged?.() callbacks.onDividerRatiosCommitted?.() } didMove = false @@ -275,7 +274,7 @@ export function attachDividerDrag( callbacks.refitPanesUnder(prev) callbacks.refitPanesUnder(next) - callbacks.onLayoutChanged?.('gesture') + callbacks.onLayoutChanged?.() callbacks.onDividerRatiosCommitted?.() } diff --git a/src/renderer/src/lib/pane-manager/pane-divider.test.ts b/src/renderer/src/lib/pane-manager/pane-divider.test.ts index 4c564ff8a5e0..6fb5f7090e08 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.test.ts @@ -170,9 +170,7 @@ describe('disposeDivider', () => { } as unknown as HTMLElement const refitPanesUnder = vi.fn() const onLayoutChanged = vi.fn() - const onDividerRatiosCommitted = vi.fn(() => - expect(onLayoutChanged).toHaveBeenCalledWith('gesture') - ) + const onDividerRatiosCommitted = vi.fn(() => expect(onLayoutChanged).toHaveBeenCalled()) vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) @@ -208,7 +206,7 @@ describe('disposeDivider', () => { expect(nextPane.style.flex).toBe('220 1 0%') expect(refitPanesUnder).toHaveBeenCalledWith(previousPane) expect(refitPanesUnder).toHaveBeenCalledWith(nextPane) - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() expect(onDividerRatiosCommitted).toHaveBeenCalledOnce() expect(divider.classList.remove).toHaveBeenCalledWith('is-dragging') expect(divider.releasePointerCapture).toHaveBeenCalledWith(9) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index f5ce9e51fd98..5d7192167263 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -295,7 +295,7 @@ describe('attachPaneDrag', () => { expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() }) it('drops onto an external target when no pane target is under the pointer', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 57b3af24044b..deba3467bd45 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -8,7 +8,6 @@ import type { PaneExternalDropTarget, PaneStyleOptions } from './pane-manager-types' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -33,7 +32,7 @@ export type DragReorderCallbacks = { applyDividerStyles: () => void refitPanesUnder: (el: HTMLElement) => void requestPaneReparentFrame?: (callback: FrameRequestCallback) => void - onLayoutChanged?: (intent?: PaneLayoutEditIntent) => void + onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void resolveExternalDropTarget?: PaneExternalDropResolver onExternalPaneDrop?: PaneExternalDropHandler @@ -154,7 +153,7 @@ function movePaneNextTo( callbacks.applyPaneOpacity() callbacks.applyDividerStyles() updateMultiPaneState(callbacks) - callbacks.onLayoutChanged?.('gesture') + callbacks.onLayoutChanged?.() } export function showDropOverlay(state: DragReorderState): void { diff --git a/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts index 9612a21fab8b..cb050312454f 100644 --- a/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts @@ -135,7 +135,7 @@ describe('handlePaneDrop with a drop taker', () => { expect(onLayoutChanged).not.toHaveBeenCalled() move?.() - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() }) it('draws the same tree main saves for a move out of a nested, resized split', () => { @@ -206,6 +206,6 @@ describe('handlePaneDrop with a drop taker', () => { handlePaneDrop(3, 1, 'left', createDragReorderState(), callbacks) - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-layout-gesture.test.ts b/src/renderer/src/lib/pane-manager/pane-layout-change-notify.test.ts similarity index 80% rename from src/renderer/src/lib/pane-manager/pane-layout-gesture.test.ts rename to src/renderer/src/lib/pane-manager/pane-layout-change-notify.test.ts index 98b3cd2273f4..5e75c63cc1d1 100644 --- a/src/renderer/src/lib/pane-manager/pane-layout-gesture.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-layout-change-notify.test.ts @@ -11,8 +11,8 @@ function makePane(flex: string): HTMLElement { return pane } -describe('pane layout gesture marker', () => { - it('marks a divider double-click reset as a gesture', () => { +describe('pane layout change notification', () => { + it('reports a divider double-click reset', () => { const onLayoutChanged = vi.fn() const split = document.createElement('div') const divider = createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) @@ -20,10 +20,10 @@ describe('pane layout gesture marker', () => { divider.dispatchEvent(new MouseEvent('dblclick')) - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() }) - it('marks an equalize command as a gesture only when it changes sizes', () => { + it('reports an equalize command only when it changes sizes', () => { const onLayoutChanged = vi.fn() const root = document.createElement('div') const split = document.createElement('div') @@ -40,6 +40,6 @@ describe('pane layout gesture marker', () => { equalizeManagedPaneSizes(panes, root, onLayoutChanged) equalizeManagedPaneSizes(panes, root, onLayoutChanged) - expect(onLayoutChanged).toHaveBeenCalledExactlyOnceWith('gesture') + expect(onLayoutChanged).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts b/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts index 083c74d8e04b..c7e60bb11184 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts @@ -1,5 +1,4 @@ import type { ManagedPaneInternal } from './pane-manager-types' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { equalizePaneSplitSizes } from './pane-tree-ops' import { fitRevealedPane } from './pane-reveal-fit' @@ -26,7 +25,7 @@ export function refreshAllPaneTerminals(panes: Map) export function equalizeManagedPaneSizes( panes: Map, root: HTMLElement, - onLayoutChanged?: (intent?: PaneLayoutEditIntent) => void + onLayoutChanged?: () => void ): void { if (panes.size < 2) { return @@ -39,5 +38,5 @@ export function equalizeManagedPaneSizes( return } - onLayoutChanged?.('gesture') + onLayoutChanged?.() } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 89d4ed2a2268..dc0357403194 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -10,7 +10,6 @@ import type { SerializeAddon } from '@xterm/addon-serialize' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import type { TerminalWebglAutoDecision } from './terminal-webgl-auto-policy' export type { TerminalScrollIntentTarget } from './terminal-scroll-intent' @@ -68,8 +67,7 @@ export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void onActivePaneChange?: (pane: ManagedPane) => void - // Why: 'gesture' lets persistence tell a user's layout edit apart from automatic updates. - onLayoutChanged?: (intent?: PaneLayoutEditIntent) => void + onLayoutChanged?: () => void /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index a4ae1968e523..a71a4a0ecda0 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -4,7 +4,6 @@ import type { ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types' -import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import { createDivider, disposeDivider } from './pane-divider' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' import { safeFit } from './pane-fit' @@ -27,7 +26,7 @@ type TreeOpsCallbacks = { getStyleOptions: () => PaneStyleOptions safeFit: (pane: ManagedPane) => void refitPanesUnder: (el: HTMLElement) => void - onLayoutChanged?: (intent?: PaneLayoutEditIntent) => void + onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void onDividerRatiosCommitted?: () => void isDestroyed?: () => boolean diff --git a/src/renderer/src/lib/pending-creation-provisioning-log.ts b/src/renderer/src/lib/pending-creation-provisioning-log.ts new file mode 100644 index 000000000000..5be89aa8a7a4 --- /dev/null +++ b/src/renderer/src/lib/pending-creation-provisioning-log.ts @@ -0,0 +1,32 @@ +import { useAppStore } from '@/store' + +const MAX_PROVISIONING_LOG_CHARS = 12_000 + +export function appendProvisioningWarnings( + creationId: string, + warnings: readonly { message: string; remediation?: string }[] +): void { + if (warnings.length === 0) { + return + } + const text = warnings + .map((warning) => + warning.remediation + ? `Warning: ${warning.message}\n${warning.remediation}\n` + : `Warning: ${warning.message}\n` + ) + .join('') + appendProvisioningLog(creationId, text) +} + +export function appendProvisioningLog(creationId: string, chunk: string): void { + const store = useAppStore.getState() + const entry = store.pendingWorktreeCreations[creationId] + if (!entry) { + return + } + // Why: recipe stdout contains the structured result with pairing credentials; + // only stderr is displayed, and the in-memory tail is bounded. + const nextLog = `${entry.provisioningLog ?? ''}${chunk}`.slice(-MAX_PROVISIONING_LOG_CHARS) + store.updatePendingWorktreeCreation(creationId, { provisioningLog: nextLog }) +} diff --git a/src/renderer/src/lib/resolve-owner-acceptance.test.ts b/src/renderer/src/lib/resolve-owner-acceptance.test.ts index 138644ee62b4..aa0233914ca9 100644 --- a/src/renderer/src/lib/resolve-owner-acceptance.test.ts +++ b/src/renderer/src/lib/resolve-owner-acceptance.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from 'vitest' import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' import type { HostAuthority } from '../../../shared/host-authority' -import { - getRuntimeEnvironmentIdForWorktree, - resolveOwnerWithLegacyFallback -} from './worktree-runtime-owner' +import { resolveOwnerWithLegacyFallback } from './worktree-runtime-owner' import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture' import { LEGACY_FALLBACK_OWNER_ACCEPTANCE } from './resolve-owner-acceptance.test-fixture' +import { LEGACY_RUNTIME_OWNER_GOLDEN } from './legacy-runtime-owner-answers.test-fixture' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' function describeMatch(match: ExecutionHostOwnerMatch): string { @@ -50,10 +48,9 @@ describe('resolveOwnerWithLegacyFallback acceptance (round-3 design ยง5)', () => owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment' ? owner.owner.endpoint.environmentId : null - return ( - owner.kind !== 'resolved' || - environmentId !== getRuntimeEnvironmentIdForWorktree(c.state, c.worktreeId) - ) + const legacy = LEGACY_RUNTIME_OWNER_GOLDEN[c.name] + // Why: the deprecated environment-id reader collapsed `ambiguous` to `null`. + return owner.kind !== 'resolved' || environmentId !== (legacy === 'ambiguous' ? null : legacy) }) for (const c of changed) { expect(DELIBERATE_CHANGES[c.worktreeId], c.name).toBeDefined() @@ -117,6 +114,5 @@ describe('resolveOwnerWithLegacyFallback probes', () => { } } expect(ownerOf(withDetected, 'repo-twin-only::wt')).toBe('self@local') - expect(getRuntimeEnvironmentIdForWorktree(withDetected, 'repo-twin-only::wt')).toBeNull() }) }) diff --git a/src/renderer/src/lib/runtime-agent-background-create.ts b/src/renderer/src/lib/runtime-agent-background-create.ts index 4733be96db5a..d75165dec046 100644 --- a/src/renderer/src/lib/runtime-agent-background-create.ts +++ b/src/renderer/src/lib/runtime-agent-background-create.ts @@ -12,7 +12,10 @@ import { } from '@/runtime/agent-session-create-operation' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { runRemoteAgentSessionLaunch } from '@/runtime/remote-agent-session-launch' +import { + assertRemoteAgentThinkingSupported, + runRemoteAgentSessionLaunch +} from '@/runtime/remote-agent-session-launch' export async function createRuntimeAgentBackgroundTerminal(args: { environmentId: string @@ -37,14 +40,24 @@ export async function createRuntimeAgentBackgroundTerminal(args: { const launchPreferences = toAgentLaunchPreferences(args.sessionOptions) return await runRemoteAgentSessionLaunch({ environmentId: args.environmentId, + agent: args.agent, + launchPreferences, hostAuthority: () => - operation.run(async (clientOperationId) => - callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + operation.run(async (clientOperationId) => { + const keyboard = await keyboardOptions(args.environmentId) + if (args.agent === 'omp') { + await assertRemoteAgentThinkingSupported({ + environmentId: args.environmentId, + agent: args.agent, + launchPreferences + }) + } + return callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, 'terminal.createAgentSession', withAgentSessionCreateOperationId( { - ...(await keyboardOptions(args.environmentId)), + ...keyboard, worktree: toRuntimeWorktreeSelector(args.worktreeId), agent: args.agent, ...(args.prompt @@ -59,7 +72,7 @@ export async function createRuntimeAgentBackgroundTerminal(args: { ), { timeoutMs: 15_000 } ) - ), + }), legacy: ({ skipCompatibilityCheck }) => callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, diff --git a/src/renderer/src/lib/runtime-owner-alias-golden.test.ts b/src/renderer/src/lib/runtime-owner-alias-golden.test.ts deleted file mode 100644 index 4d5ff92a6198..000000000000 --- a/src/renderer/src/lib/runtime-owner-alias-golden.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - getRuntimeEnvironmentIdForWorktree, - resolveRuntimeOwnerForWorktree -} from './worktree-runtime-owner' -import { resolveOwner } from './resolve-owner' -import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture' -import { LEGACY_RUNTIME_OWNER_GOLDEN } from './runtime-owner-alias-golden.test-fixture' - -describe('the deprecated runtime-owner resolvers', () => { - it('map every case exactly as on main before resolveOwner replaced them', () => { - const actual = Object.fromEntries( - OWNER_RESOLUTION_CASES.map((c) => { - const owner = resolveRuntimeOwnerForWorktree(c.state, c.worktreeId) - const environmentId = getRuntimeEnvironmentIdForWorktree(c.state, c.worktreeId) - expect(environmentId, c.name).toBe(owner.kind === 'runtime' ? owner.environmentId : null) - const value = - owner.kind === 'runtime' - ? owner.environmentId - : owner.kind === 'ambiguous' - ? 'ambiguous' - : null - return [c.name, value] - }) - ) - expect(actual).toEqual(LEGACY_RUNTIME_OWNER_GOLDEN) - }) -}) - -describe('resolveOwner on the deprecated resolversโ€™ blind spots', () => { - const byName = new Map(OWNER_RESOLUTION_CASES.map((c) => [c.name, c])) - const ownerOf = (name: string) => { - const c = byName.get(name) - if (!c?.worktreeId) { - throw new Error(`no case ${name}`) - } - return resolveOwner(c.state, { workspaceId: c.worktreeId }) - } - - it('refuses folder and project-group twins the deprecated resolver sends to the focused runtime', () => { - expect(LEGACY_RUNTIME_OWNER_GOLDEN['single focus | folder:twin-folder']).toBe('focused-env') - expect(ownerOf('single focus | folder:twin-folder')).toEqual({ kind: 'ambiguous' }) - expect(LEGACY_RUNTIME_OWNER_GOLDEN['single focus | folder:group-twin-folder']).toBe( - 'focused-env' - ) - expect(ownerOf('single focus | folder:group-twin-folder')).toEqual({ kind: 'ambiguous' }) - }) - - it('keeps the SSH target behind a server the deprecated resolver reduces to an environment id', () => { - expect(LEGACY_RUNTIME_OWNER_GOLDEN['focused | hub-repo::wt']).toBe('hub-env') - expect(ownerOf('focused | hub-repo::wt')).toEqual({ - kind: 'resolved', - owner: { endpoint: { kind: 'environment', environmentId: 'hub-env' }, at: 'ssh:box' } - }) - }) - - it('keeps git-worktree twins ambiguous, as the deprecated resolver already does', () => { - expect(ownerOf('focused | twin-repo::wt')).toEqual({ kind: 'ambiguous' }) - }) -}) diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 01bb94ad11b8..a9646e113475 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -26,24 +26,30 @@ export async function activateWorktreeFromSidebar( ...(executionHostId ? { executionHostId } : {}) }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.resumeWorkspace) { - try { - const runtime = await window.api.ephemeralVm.resumeWorkspace({ workspaceId: worktreeId }) - if (runtime?.runtimeEnvironmentId) { - const store = (await import('@/store')).useAppStore - store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) - await store.getState().refreshRuntimeEnvironmentStatus(runtime.runtimeEnvironmentId) - } - } catch (error) { - toast.error( - translate( - 'auto.lib.sidebarWorktreeActivation.wakeEphemeralVmFailed', - 'Failed to wake ephemeral VM workspace' - ), - { - description: error instanceof Error ? error.message : String(error) - } - ) + await resumeEphemeralVmWorkspace(worktreeId) +} + +/** Wakes the per-workspace VM behind a worktree, if it has one. */ +async function resumeEphemeralVmWorkspace(worktreeId: string): Promise { + if (typeof window === 'undefined' || !window.api?.ephemeralVm?.resumeWorkspace) { + return + } + try { + const runtime = await window.api.ephemeralVm.resumeWorkspace({ workspaceId: worktreeId }) + if (runtime?.runtimeEnvironmentId) { + const store = (await import('@/store')).useAppStore + store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) + await store.getState().refreshRuntimeEnvironmentStatus(runtime.runtimeEnvironmentId) } + } catch (error) { + toast.error( + translate( + 'auto.lib.sidebarWorktreeActivation.wakeEphemeralVmFailed', + 'Failed to wake ephemeral VM workspace' + ), + { + description: error instanceof Error ? error.message : String(error) + } + ) } } diff --git a/src/renderer/src/lib/structured-agent-session-host-admission.ts b/src/renderer/src/lib/structured-agent-session-host-admission.ts index 93f7c8c00bfc..bd2b020457d9 100644 --- a/src/renderer/src/lib/structured-agent-session-host-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-host-admission.ts @@ -30,7 +30,7 @@ function runtimeErrorCode(error: unknown): string { * An admitting host also names the saved selection create will seed, when it is new enough to. */ export type StructuredLaunchAdmission = | { kind: 'admitted'; seedOptions?: Readonly> } - | { kind: 'declined' } + | { kind: 'declined'; reason?: string } | { kind: 'unreachable' } /** The host still could not resolve the workspace after the retries: not yet, rather than no. */ | { kind: 'workspace-unresolved' } @@ -58,7 +58,12 @@ export async function askHostCreateSupport( seedOptions?: unknown }>(target, 'agentSession.createSupport', { worktree, agent }) if (support.supported !== true) { - return { kind: 'declined' } + return { + kind: 'declined', + ...(typeof support.reason === 'string' && support.reason.trim() + ? { reason: support.reason } + : {}) + } } const seedOptions = parseStructuredLaunchSeedOptions(support.seedOptions) return seedOptions ? { kind: 'admitted', seedOptions } : { kind: 'admitted' } diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 03729d6eeff2..248a7ed18a03 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -429,3 +429,27 @@ describe('a paired server that cannot resolve a new workspace yet', () => { expect(onHostDeclined).toHaveBeenCalledOnce() }) }) + +it('keeps explicit refusal owned by admission and keeps an opened chat failure out of the menu', async () => { + for (const supported of [false, true]) { + mocks.toastError.mockClear() + mocks.createSupport.mockResolvedValue({ supported, reason: 'wsl' }) + const plan = adoptAgentSessionLaunchVerdict({ ...pairedPlan(), requestedSurface: 'chat' }) + if (supported) { + mocks.createSupport + .mockResolvedValueOnce({ supported: true }) + .mockResolvedValueOnce({ supported: false }) + } + const onHostDeclined = vi.fn() + const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, onHostDeclined }) + await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' }) + expect(onHostDeclined).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledTimes(supported ? 0 : 1) + if (!supported) { + await expect(launch?.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + failureNotified: true + }) + } + } +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index f767a4313fe1..1be3604a8718 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -50,6 +50,37 @@ type AdmittedLaunch = { const DELIVERED: StructuredPromptDeliveryResult = { delivered: true, failureNotified: false } const NOT_DELIVERED: StructuredPromptDeliveryResult = { delivered: false, failureNotified: false } +const NOT_DELIVERED_NOTIFIED: StructuredPromptDeliveryResult = { + delivered: false, + failureNotified: true +} + +function notifyExplicitChatFailure(agent: TuiAgent, reason?: string): void { + toast.error( + translate( + 'auto.components.tab.bar.QuickLaunchButton.chatUnavailableAfterSelection', + "Couldn't start a {{value0}} chat.", + { value0: structuredAgentLabel(agent) } + ), + { + description: + reason === 'wsl' + ? translate( + 'components.native-chat.explicitChatWsl', + 'Chat is not available in WSL. Open the agent in a terminal instead.' + ) + : reason === 'remote' + ? translate( + 'components.native-chat.explicitChatSsh', + 'Chat is not available over SSH. Open the agent in a terminal instead.' + ) + : translate( + 'components.native-chat.explicitChatUnavailable', + 'This workspace cannot run the chat. Check the agent installation or open it in a terminal.' + ) + } + ) +} function notifyHostDeclined(agent: TuiAgent): void { const agentLabel = structuredAgentLabel(agent) @@ -159,6 +190,15 @@ export function beginHostAdmittedStructuredLaunch(args: { resolveDelivery = resolve }) : undefined + const failBeforeSurface = (error: unknown, reason?: string): StructuredAgentLaunchSettlement => { + if (plan.requestedSurface === 'chat' && !admitted) { + notifyExplicitChatFailure(plan.agent, reason) + resolveDelivery(NOT_DELIVERED_NOTIFIED) + return { kind: 'failed', error, notified: true } + } + resolveDelivery(NOT_DELIVERED) + return { kind: 'failed', error } + } // Released once the surface that answers the launch has opened, or nothing will. const releaseHold = holdEmptyWorkspaceDefaultSurfaceForLaunch(args.worktreeId) const settlement = (async (): Promise => { @@ -190,20 +230,16 @@ export function beginHostAdmittedStructuredLaunch(args: { } } if (admission.kind === 'declined') { - if (plan.resumeFrom) { - resolveDelivery(NOT_DELIVERED) - return { - kind: 'failed', - error: new StructuredAgentSessionCreateRefusalError( - 'structured_agent_session_unsupported' - ) - } + if (plan.resumeFrom || plan.requestedSurface === 'chat') { + return failBeforeSurface( + new StructuredAgentSessionCreateRefusalError('structured_agent_session_unsupported'), + admission.reason + ) } - // The notice explains a server's refusal; on this machine the terminal opening is the answer. - if (paired) { + const terminal = await args.onHostDeclined(args.target) + if (paired && terminal.opened) { notifyHostDeclined(plan.agent) } - const terminal = await args.onHostDeclined(args.target) void ( terminal.promptDeliveryResult ?? Promise.resolve(terminal.opened ? DELIVERED : NOT_DELIVERED) @@ -242,10 +278,7 @@ export function beginHostAdmittedStructuredLaunch(args: { } finally { releaseHold() } - })().catch((error: unknown): StructuredAgentLaunchSettlement => { - resolveDelivery(NOT_DELIVERED) - return { kind: 'failed', error } - }) + })().catch(failBeforeSurface) return { sessionId: null, tab: null, diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts index 54ea794ab283..30acb7d58e10 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-failure.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -11,7 +11,10 @@ import { type AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' -export type StructuredLaunchFailure = AgentSessionWriteRefusal & { authStartupMessage?: string } +export type StructuredLaunchFailure = AgentSessionWriteRefusal & { + authStartupMessage?: string + owner?: AgentSessionConversationOwner +} /** The host's refusal behind a failed launch; undefined when the failure carried none. */ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure | undefined { @@ -19,7 +22,14 @@ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure if (!refusal) { return undefined } - const failure = agentSessionRefusalFailure(refusal) + const owner = + refusal.code === 'agent_session_conflict' || refusal.code === 'agent_session_ownership_unknown' + ? refusal.details?.owner + : undefined + const failure = { + ...agentSessionRefusalFailure(refusal), + ...(owner ? { owner } : {}) + } const message = error instanceof Error ? error.message : undefined return failure.code === 'agent_session_operation_invalid' && failure.details?.reason === 'notSignedIn' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts index 9872e63785e8..058a6923d39c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.test.ts @@ -162,6 +162,25 @@ describe('structured agent launch persistence', () => { }) }) + it("preserves another agent's config-default effort when restoring a paired-host launch", () => { + writeStructuredAgentLaunchRecord({ + sessionId: 'grok_paired', + executionHostId: SERVER, + agent: 'grok', + lifecycle: 'pending', + clientOperationId: 'operation-grok', + payloadFingerprint: 'fingerprint-grok', + expectedRuntimeFence: null, + seedOptions: { model: 'custom/model', effort: 'config-default' } + }) + reload() + + expect(readStructuredAgentLaunchRecord('grok_paired')?.seedOptions).toEqual({ + model: 'custom/model', + effort: 'config-default' + }) + }) + it('stores only content-free identity and preserves operation identity', () => { writeStructuredAgentLaunchRecord({ sessionId: 'claude_session', diff --git a/src/renderer/src/lib/tab-agent-remote-pty-selector.ts b/src/renderer/src/lib/tab-agent-remote-pty-selector.ts index 69091611d471..7e24f8412b54 100644 --- a/src/renderer/src/lib/tab-agent-remote-pty-selector.ts +++ b/src/renderer/src/lib/tab-agent-remote-pty-selector.ts @@ -1,4 +1,4 @@ -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' /** Checks both PTY projections without allocating a dedupe set on each store notification. */ export function hasRemoteRuntimePtyForTab( diff --git a/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts b/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts new file mode 100644 index 000000000000..9d5d261ecace --- /dev/null +++ b/src/renderer/src/lib/terminal-pty-owner-acceptance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalHostOwnership, resolveTerminalPtyOwner } from './terminal-worktree-route' +import { resolveOwner } from './resolve-owner' +import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture' +import { LEGACY_RUNTIME_OWNER_GOLDEN } from './legacy-runtime-owner-answers.test-fixture' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' + +const describeOwner = (owner: ReturnType) => + owner.kind === 'runtime' ? owner.environmentId : owner.kind === 'ambiguous' ? 'ambiguous' : null + +// Each deliberate change from the deleted resolver's teardown answer (round-3 design ยง3, ยง4, ยง7). +const DELIBERATE_CHANGES: Record = { + // C: teardown follows spawn, which already starts these PTYs on the server. + 'focused | stale-hub-repo::wt': 'hub-env', + 'no focus | stale-hub-repo::wt': 'hub-env', + 'single focus | stale-hub-repo::wt': 'hub-env', + // D77: a folder twin names no host; only each PTY's own `remote:` id may. + 'focused | folder:twin-folder': 'ambiguous', + 'no focus | folder:twin-folder': 'ambiguous', + 'single focus | folder:twin-folder': 'ambiguous', + 'focused | folder:group-twin-folder': 'ambiguous', + 'no focus | folder:group-twin-folder': 'ambiguous', + 'single focus | folder:group-twin-folder': 'ambiguous', + // B: two servers contest the selected SSH host, so spawn refuses and teardown must too. + 'active ssh:box | rival-repo::wt': 'ambiguous', + 'active ssh:box | conflict-repo::wt': 'ambiguous' +} + +describe('resolveTerminalPtyOwner acceptance', () => { + it('answers as the deleted resolver did, except the deliberate cases', () => { + const actual = Object.fromEntries( + OWNER_RESOLUTION_CASES.map((c) => [ + c.name, + describeOwner(resolveTerminalPtyOwner(c.state, c.worktreeId)) + ]) + ) + expect(actual).toEqual({ ...LEGACY_RUNTIME_OWNER_GOLDEN, ...DELIBERATE_CHANGES }) + for (const name of Object.keys(DELIBERATE_CHANGES)) { + expect(LEGACY_RUNTIME_OWNER_GOLDEN[name], name).not.toBe(DELIBERATE_CHANGES[name]) + } + }) + + it('keeps a setup terminal and a row-less worktree local under single focus', () => { + const single = OWNER_RESOLUTION_CASES.find((c) => c.name === 'single focus | legacy-repo::wt')! + expect(resolveTerminalPtyOwner(single.state, 'ephemeral-setup-terminal:skill-panel')).toEqual({ + kind: 'non-runtime' + }) + expect(resolveTerminalPtyOwner(single.state, 'unknown-repo::wt')).toEqual({ + kind: 'non-runtime' + }) + }) +}) + +describe('the contested-target rule for a selected ssh: host', () => { + const directAndProxied: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + runtimeEnvironments: [{ id: 'hub-env' }], + runtimeEnvironmentCatalogHydrated: true, + repos: [ + { id: 'repo', connectionId: 'box', executionHostId: 'ssh:box' }, + { id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-env' } + ], + worktreesByRepo: { repo: [{ id: 'repo::wt', repoId: 'repo', hostId: 'ssh:box' }] }, + activeWorktreeId: 'repo::wt', + activeWorkspaceExecutionHostId: 'ssh:box' + } + + it('dials a proven direct listing directly although a server proxies the same target id', () => { + expect(resolveOwner(directAndProxied, { workspaceId: 'repo::wt' })).toEqual({ + kind: 'resolved', + owner: { endpoint: { kind: 'self' }, at: 'ssh:box' } + }) + expect(resolveTerminalHostOwnership(directAndProxied, 'repo::wt', 'spawn').kind).toBe( + 'local-or-ssh' + ) + expect(resolveTerminalPtyOwner(directAndProxied, 'repo::wt')).toEqual({ kind: 'non-runtime' }) + }) + + it('refuses when no direct repo row backs the listing', () => { + const proxiedTwice: WorktreeRuntimeOwnerState = { + ...directAndProxied, + repos: [ + { id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-a' }, + { id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-b' } + ] + } + expect(resolveOwner(proxiedTwice, { workspaceId: 'repo::wt' })).toEqual({ kind: 'ambiguous' }) + expect(resolveTerminalHostOwnership(proxiedTwice, 'repo::wt', 'spawn').kind).toBe('unresolved') + expect(resolveTerminalPtyOwner(proxiedTwice, 'repo::wt')).toEqual({ kind: 'ambiguous' }) + }) + + it('still dials a proven direct listing when two servers proxy the same target id', () => { + const directAndTwoProxies: WorktreeRuntimeOwnerState = { + ...directAndProxied, + repos: [ + { id: 'repo', connectionId: 'box', executionHostId: 'ssh:box' }, + { id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-a' }, + { id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-b' } + ] + } + expect(resolveOwner(directAndTwoProxies, { workspaceId: 'repo::wt' })).toEqual({ + kind: 'resolved', + owner: { endpoint: { kind: 'self' }, at: 'ssh:box' } + }) + expect(resolveTerminalPtyOwner(directAndTwoProxies, 'repo::wt')).toEqual({ + kind: 'non-runtime' + }) + }) +}) diff --git a/src/renderer/src/lib/terminal-worktree-route.ts b/src/renderer/src/lib/terminal-worktree-route.ts index de71d3931635..948b8aaefb76 100644 --- a/src/renderer/src/lib/terminal-worktree-route.ts +++ b/src/renderer/src/lib/terminal-worktree-route.ts @@ -4,10 +4,19 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' import { resolveOwnerWithLegacyFallback } from './worktree-runtime-owner' -import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner' +import { namedHostForOwnerRef, runtimeEnvironmentIdUnlessTwin } from './resolve-owner' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' -import { resolveWorktreeOperationRouteResult } from './worktree-operation-route' +import { + resolveStrictWorktreeOperationRouteResultForHost, + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteResolution +} from './worktree-operation-route' import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner' +import { + hasIndexedDetectedWorktree, + resolveIndexedRepoOwner, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' export type TerminalWorktreeRoute = { runtimeEnvironmentId: string | null @@ -86,7 +95,7 @@ export function resolveTerminalHostOwnership( getSingleFocusedRuntimeEnvironmentId(state) ) } - const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) + const resolution = resolveGitWorktreeRoute(state, worktreeId) if (resolution.kind === 'resolved') { if (resolution.route.runtimeEnvironmentId) { // Why: a real worktree row keeps its runtime owner on teardown. Unlike the host-agnostic @@ -106,12 +115,86 @@ export function resolveTerminalHostOwnership( state.repos === undefined ) { // Why: narrow unit/legacy adapters can omit all owner catalogs; production stores always provide - // them and still fail closed above. Teardown never takes this fail-open โ€” it would kill on a guess. + // them and still fail closed above. The `teardown` purpose never takes this fail-open. return ownershipForRuntimeEnvironmentId(getSingleFocusedRuntimeEnvironmentId(state)) } return UNRESOLVED_TERMINAL_HOST } +/** A named host takes the strict route, so rival servers for one `ssh:` target never read as direct. */ +function resolveGitWorktreeRoute( + state: WorktreeRuntimeOwnerState, + worktreeId: string +): WorktreeOperationRouteResolution { + const hostId = namedHostForOwnerRef(state, { workspaceId: worktreeId }) + return hostId + ? resolveStrictWorktreeOperationRouteResultForHost(state, worktreeId, hostId) + : resolveWorktreeOperationRouteResult(state, worktreeId) +} + +export type TerminalPtyOwner = + | { kind: 'runtime'; environmentId: string } + | { kind: 'non-runtime' } + | { kind: 'ambiguous' } + +const NON_RUNTIME_PTY_OWNER: TerminalPtyOwner = { kind: 'non-runtime' } +const AMBIGUOUS_PTY_OWNER: TerminalPtyOwner = { kind: 'ambiguous' } + +/** + * Where spawn started a worktree's PTYs, for teardown, close and PTY identity. A contested or + * unattributed-twin owner is `ambiguous`; an id no row places stays this app's, as it always has. + * `spawn` is for unscoped `remote:` ids, which live where spawn put them; `teardown` keeps a setup + * terminal's raw ids local, as they always have been. + */ +export function resolveTerminalPtyOwner( + state: WorktreeRuntimeOwnerState, + worktreeId: string | null | undefined, + purpose: TerminalHostOwnershipPurpose = 'teardown' +): TerminalPtyOwner { + if (!worktreeId) { + return NON_RUNTIME_PTY_OWNER + } + const setup = isEphemeralSetupTerminalWorktreeId(worktreeId) + if ( + !setup && + !namedHostForOwnerRef(state, { workspaceId: worktreeId }) && + isUnattributedTwinWorktree(state, worktreeId) + ) { + return AMBIGUOUS_PTY_OWNER + } + const ownership = resolveTerminalHostOwnership(state, worktreeId, setup ? purpose : 'spawn') + switch (ownership.kind) { + case 'runtime': + return { kind: 'runtime', environmentId: ownership.runtimeEnvironmentId } + case 'local-or-ssh': + return NON_RUNTIME_PTY_OWNER + case 'unresolved': + return setup || + (parseWorkspaceKey(worktreeId)?.type !== 'folder' && + resolveGitWorktreeRoute(state, worktreeId).kind === 'missing') + ? NON_RUNTIME_PTY_OWNER + : AMBIGUOUS_PTY_OWNER + } +} + +/** + * An unstamped worktree row with no detected copy, in a project two hosts publish: no row says + * which host started its PTYs, so only each PTY's own `remote:` id may. + */ +export function isUnattributedTwinWorktree( + state: WorktreeRuntimeOwnerState, + worktreeId: string +): boolean { + const worktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) + return ( + worktree.kind === 'resolved' && + !worktree.owner.runtimeOwnerEnvironmentId?.trim() && + !parseExecutionHostId(worktree.owner.hostId) && + !hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) && + resolveIndexedRepoOwner(state.repos, worktree.owner.repoId).kind === 'ambiguous' + ) +} + /** * Host ownership for the host-agnostic surfaces โ€” floating, folder workspace, ephemeral setup. * Safe to downgrade on teardown because these never hold an `ssh:`-shaped HUB wake hint: a diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index f0fa00a14b5d..ec3652b8bf7a 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -115,26 +115,30 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake( const startup = opts?.startup // Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface. - void createWebRuntimeSessionTerminal({ - worktreeId, - environmentId: runtimeEnvironmentId, - ...(startup - ? { - command: startup.command, - ...(startup.env ? { env: startup.env } : {}), - ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), - ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), - ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), - ...(startup.startupCommandDelivery - ? { startupCommandDelivery: startup.startupCommandDelivery } - : {}) - } - : launchAgent - ? { agent: launchAgent } - : {}), - activate: opts?.activate !== false, - selectWorktree: false - }) + void createWebRuntimeSessionTerminal( + { + worktreeId, + environmentId: runtimeEnvironmentId, + ...(startup + ? { + command: startup.command, + ...(startup.env ? { env: startup.env } : {}), + ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), + ...(startup.launchToken ? { launchToken: startup.launchToken } : {}), + ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}) + } + : launchAgent + ? { agent: launchAgent } + : {}), + activate: opts?.activate !== false, + selectWorktree: false + }, + // This flow toasts every failure itself, including a disconnected host. + { notifyFailure: false } + ) .then((outcome) => { if (outcome.status === 'failed') { toast.error(outcome.message, { diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts new file mode 100644 index 000000000000..dc7519f9324c --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshConnectionState } from '../../../shared/ssh-types' +import type { WorkspaceSessionSnapshot } from './workspace-session' +import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' + +function connected(targetId: string): [string, SshConnectionState] { + return [targetId, { targetId, status: 'connected', error: null, reconnectAttempt: 0 }] +} + +describe('buildActiveConnectionIdsAtShutdown', () => { + it('never asks startup to dial an Orca-owned target', () => { + const owned = ['runtime-ssh-vm-1', 'machine-0123'] + const snapshot = { + sshConnectionStates: new Map([connected('ssh-user'), ...owned.map(connected)]) + } + const remoteSessions = Object.fromEntries( + owned.map((id, index) => [`tab-${index}`, toAppSshPtyId(id, 'pty-1')]) + ) + expect( + buildActiveConnectionIdsAtShutdown( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only sshConnectionStates. + snapshot as WorkspaceSessionSnapshot, + remoteSessions + ) + ).toEqual(['ssh-user']) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts index 1dc2ae717e1a..e7baec33c812 100644 --- a/src/renderer/src/lib/workspace-session-reconnect-targets.ts +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -1,6 +1,6 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' -import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import { isOwnedSshTargetId } from '../../../shared/execution-host' import type { WorkspaceSessionSnapshot } from './workspace-session' export function buildActiveConnectionIdsAtShutdown( @@ -16,9 +16,7 @@ export function buildActiveConnectionIdsAtShutdown( // pane-level optimistic write could stamp one; exclude them here too. const targetIds = new Set( Array.from(snapshot.sshConnectionStates.entries()) - .filter( - ([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId) - ) + .filter(([targetId, state]) => state.status === 'connected' && !isOwnedSshTargetId(targetId)) .map(([targetId]) => targetId) ) @@ -34,7 +32,7 @@ export function buildActiveConnectionIdsAtShutdown( // renderer-driven ssh.connect would dispose the runtime's live relay session. for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) { const connectionId = parseAppSshPtyId(sessionId)?.connectionId - if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + if (!connectionId || isOwnedSshTargetId(connectionId)) { continue } const status = snapshot.sshConnectionStates.get(connectionId)?.status diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index 6cb3c3675e27..e6b0e434aedb 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -1,5 +1,6 @@ import type { AppState } from '@/store/types' import { + getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, type ExecutionHostId @@ -136,7 +137,8 @@ export function resolveWorktreeOperationRouteForHost( /** * Strict variant of the host-qualified resolver for callers that treat a null runtime as local: - * rival HUBs projecting the same host report `ambiguous` instead of a transport-less route. + * rival HUBs projecting the same host, or rows that contradict each other about its transport, + * report `ambiguous` instead of a transport-less route. */ export function resolveStrictWorktreeOperationRouteResultForHost( state: WorktreeOperationRouteState, @@ -149,13 +151,34 @@ export function resolveStrictWorktreeOperationRouteResultForHost( } if (host.kind === 'ssh') { const hubOwners = collectHubOwnerEnvironmentIds(state, worktreeId, host.id) - if (hubOwners.hasAmbiguousOwner || hubOwners.environmentIds.size > 1) { + if ( + hubOwners.environmentIds.size > 1 || + (hubOwners.hasAmbiguousOwner && !isDirectListing(state, worktreeId, host.id, hubOwners)) + ) { return { kind: 'ambiguous' } } } return { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) } } +/** A direct repo row backs a listing no row ties to a server, so a same-id proxy cannot claim it. */ +function isDirectListing( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId, + hubOwners: { environmentIds: Set } +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + hubOwners.environmentIds.size === 0 && + Boolean( + state.repos?.some( + (repo) => repo.id === repoId && getRepoExecutionHostId(repo) === executionHostId + ) + ) + ) +} + function collectHubOwnerEnvironmentIds( state: WorktreeOperationRouteState, worktreeId: string, diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 351d001cf04c..6332bd39b680 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -7,7 +7,7 @@ import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-act import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import { makePaneKey } from '../../../shared/stable-pane-id' import type { ExecutionHostId } from '../../../shared/execution-host' -import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' import { markHostSessionMirrorHydrated, resetHostSessionMirrorHydrationForTests @@ -324,9 +324,9 @@ describe('preserved-pane replacement contract on workspace activation', () => { } })) - expect(getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktree.id)).toBe( - RUNTIME_ENV_ID - ) + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(useAppStore.getState(), worktree.id) + ).toBe(RUNTIME_ENV_ID) activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) await waitForWorktreeAgentActivationGateForTests(worktree.id) diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 0f785d827997..296e94171660 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -3,9 +3,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { getExecutionHostIdForWorktree, getKnownExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree, - resolveRuntimeOwnerForWorktree + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' +import { resolveTerminalPtyOwner } from './terminal-worktree-route' import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners' import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' import { runtimeEnvironmentIdForWorkspaceOwner } from './resolve-owner' @@ -44,13 +44,15 @@ const state: WorktreeRuntimeOwnerState = { ] } -describe('getRuntimeEnvironmentIdForWorktree', () => { +describe('runtimeEnvironmentIdWithLegacyFallbackUnlessTwin', () => { it('routes to the runtime owner of the worktree', () => { - expect(getRuntimeEnvironmentIdForWorktree(state, 'runtime-repo::wt-b')).toBe('owner-env') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'runtime-repo::wt-b')).toBe( + 'owner-env' + ) }) it('keeps explicit-local worktrees local even while a runtime is focused', () => { - expect(getRuntimeEnvironmentIdForWorktree(state, 'local-repo::wt-a')).toBeNull() + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'local-repo::wt-a')).toBeNull() }) it('keeps the synthetic floating workspace local while a runtime is focused', () => { @@ -60,7 +62,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { activeWorkspaceExecutionHostId: 'runtime:focused-env' } expect( - getRuntimeEnvironmentIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID) + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(floatingState, FLOATING_TERMINAL_WORKTREE_ID) ).toBeNull() expect(getKnownExecutionHostIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID)).toBe( 'local' @@ -71,7 +73,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { }) it('routes folder workspaces to their project group runtime owner', () => { - expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:runtime-folder')).toBe('folder-env') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'folder:runtime-folder')).toBe( + 'folder-env' + ) expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env') }) @@ -106,9 +110,12 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { } } - expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe( - 'restored-env' - ) + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + restoredFolderState, + 'folder:restored-folder' + ) + ).toBe('restored-env') expect(ownerEnvironmentId(restoredFolderState, 'folder:restored-folder')).toBe('restored-env') expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe( 'runtime:restored-env' @@ -116,7 +123,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { }) it('keeps explicit-local folder workspaces local even while a runtime is focused', () => { - expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:local-folder')).toBeNull() + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'folder:local-folder') + ).toBeNull() expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local') const restoredOwnerState: WorktreeRuntimeOwnerState = { @@ -125,7 +134,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { 'folder:local-folder': 'runtime:stale-env' } } - expect(getRuntimeEnvironmentIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBeNull() + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(restoredOwnerState, 'folder:local-folder') + ).toBeNull() expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local') }) @@ -139,7 +150,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { } expect( - getRuntimeEnvironmentIdForWorktree(folderConnectionState, 'folder:folder-ssh') + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(folderConnectionState, 'folder:folder-ssh') ).toBeNull() expect(getExecutionHostIdForWorktree(folderConnectionState, 'folder:folder-ssh')).toBe( 'ssh:folder-remote' @@ -155,7 +166,10 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { } expect( - getRuntimeEnvironmentIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder') + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + staleFolderConnectionState, + 'folder:runtime-folder' + ) ).toBe('folder-env') expect(getExecutionHostIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder')).toBe( 'runtime:folder-env' @@ -163,7 +177,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => { }) }) -describe('resolveRuntimeOwnerForWorktree', () => { +describe('resolveTerminalPtyOwner', () => { it('names the runtime owner, a non-runtime owner, and keeps a twin ambiguous', () => { const twinState: WorktreeRuntimeOwnerState = { ...state, @@ -175,18 +189,18 @@ describe('resolveRuntimeOwnerForWorktree', () => { ] } } - expect(resolveRuntimeOwnerForWorktree(twinState, 'runtime-repo::wt-b')).toEqual({ + expect(resolveTerminalPtyOwner(twinState, 'runtime-repo::wt-b')).toEqual({ kind: 'runtime', environmentId: 'owner-env' }) - expect(resolveRuntimeOwnerForWorktree(twinState, 'local-repo::wt-a')).toEqual({ + expect(resolveTerminalPtyOwner(twinState, 'local-repo::wt-a')).toEqual({ kind: 'non-runtime' }) - expect(resolveRuntimeOwnerForWorktree(twinState, 'twin-repo::wt')).toEqual({ + expect(resolveTerminalPtyOwner(twinState, 'twin-repo::wt')).toEqual({ kind: 'ambiguous' }) - // Why: read-only callers keep the old collapse to "local". - expect(getRuntimeEnvironmentIdForWorktree(twinState, 'twin-repo::wt')).toBeNull() + // Why: read-only callers keep the old collapse to "no server". + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(twinState, 'twin-repo::wt')).toBeNull() }) }) @@ -214,9 +228,9 @@ describe('the explicit owner environment (resolveOwner)', () => { } expect(ownerEnvironmentId(nestedState, 'nested-repo::remote-worktree')).toBe('owner-hub') - expect(getRuntimeEnvironmentIdForWorktree(nestedState, 'nested-repo::remote-worktree')).toBe( - 'owner-hub' - ) + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(nestedState, 'nested-repo::remote-worktree') + ).toBe('owner-hub') expect(getExecutionHostIdForWorktree(nestedState, 'nested-repo::remote-worktree')).toBe( 'ssh:hub-private-ssh' ) @@ -335,8 +349,12 @@ describe('the explicit owner environment (resolveOwner)', () => { } } - expect(getRuntimeEnvironmentIdForWorktree(multiHubState, 'repo-a::ssh-worktree')).toBe('hub-a') - expect(getRuntimeEnvironmentIdForWorktree(multiHubState, 'repo-b::ssh-worktree')).toBe('hub-b') + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(multiHubState, 'repo-a::ssh-worktree') + ).toBe('hub-a') + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(multiHubState, 'repo-b::ssh-worktree') + ).toBe('hub-b') expect(getExecutionHostIdForWorktree(multiHubState, 'repo-a::ssh-worktree')).toBe( 'ssh:private-a' ) @@ -346,7 +364,9 @@ describe('the explicit owner environment (resolveOwner)', () => { }) it('does not treat the focused runtime as ownership for legacy-local worktrees', () => { - expect(getRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('focused-env') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'legacy-repo::wt-legacy')).toBe( + 'focused-env' + ) expect(ownerEnvironmentId(state, 'legacy-repo::wt-legacy')).toBeNull() }) @@ -373,7 +393,10 @@ describe('the explicit owner environment (resolveOwner)', () => { expect(ownerEnvironmentId(hostOverrideState, 'runtime-repo::wt-local-override')).toBeNull() expect( - getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override') + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + hostOverrideState, + 'runtime-repo::wt-local-override' + ) ).toBeNull() expect( getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override') @@ -382,7 +405,10 @@ describe('the explicit owner environment (resolveOwner)', () => { 'worktree-env' ) expect( - getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override') + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + hostOverrideState, + 'runtime-repo::wt-runtime-override' + ) ).toBe('worktree-env') expect( getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override') @@ -469,10 +495,13 @@ describe('runtime owner identity indexes', () => { } for (let lookup = 0; lookup < 200; lookup += 1) { - getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId) + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(indexedState, targetWorktreeId) ownerEnvironmentId(indexedState, targetWorktreeId) getExecutionHostIdForWorktree(indexedState, targetWorktreeId) - getRuntimeEnvironmentIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`) + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin( + indexedState, + `folder:${targetFolderWorkspaceId}` + ) ownerEnvironmentId(indexedState, `folder:${targetFolderWorkspaceId}`) getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`) } @@ -482,7 +511,9 @@ describe('runtime owner identity indexes', () => { expect(repoIdReads).toBe(200) expect(folderWorkspaceIdReads).toBe(1_000) expect(projectGroupIdReads).toBe(1_000) - expect(getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId)).toBe('repo-owner') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(indexedState, targetWorktreeId)).toBe( + 'repo-owner' + ) expect(getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`)).toBe( 'runtime:folder-owner' ) @@ -495,8 +526,10 @@ describe('runtime owner identity indexes', () => { folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }], projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-one' }] } - expect(getRuntimeEnvironmentIdForWorktree(first, 'worktree')).toBe('repo-one') - expect(getRuntimeEnvironmentIdForWorktree(first, 'folder:folder')).toBe('folder-one') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(first, 'worktree')).toBe('repo-one') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(first, 'folder:folder')).toBe( + 'folder-one' + ) const second: WorktreeRuntimeOwnerState = { repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:repo-two' }], @@ -506,8 +539,12 @@ describe('runtime owner identity indexes', () => { folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }], projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-two' }] } - expect(getRuntimeEnvironmentIdForWorktree(second, 'worktree')).toBe('worktree-two') - expect(getRuntimeEnvironmentIdForWorktree(second, 'folder:folder')).toBe('folder-two') + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(second, 'worktree')).toBe( + 'worktree-two' + ) + expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(second, 'folder:folder')).toBe( + 'folder-two' + ) }) }) @@ -609,7 +646,9 @@ describe('active workspace host selection', () => { } it('keeps the HUB transport for the active paired SSH worktree', () => { - expect(getRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a') + expect( + runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(pairedHubState, PAIRED_HUB_WORKTREE_ID) + ).toBe('hub-a') expect(ownerEnvironmentId(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a') }) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index f7b6bba946ed..5b0ab0329b6d 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -12,6 +12,7 @@ import { findIndexedRepoOwner as findRepoRecord, findIndexedWorktreeOwner as findWorktreeRecord, hasIndexedDetectedWorktree, + resolveIndexedProjectGroupOwner, resolveIndexedRepoOwner, resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' @@ -32,7 +33,6 @@ import { SELF_LOCAL_AUTHORITY, type HostAuthority } from '../../../shared/host-a import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution' import { getFloatingWorkspaceOperationRoute, - resolveActiveWorkspaceRoute, resolveWorktreeOperationRouteResult } from './worktree-operation-route' import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route' @@ -59,59 +59,22 @@ function getActiveWorkspaceExecutionHostId( : null } -export type WorktreeRuntimeOwner = - | { kind: 'runtime'; environmentId: string } - | { kind: 'non-runtime' } - // Why: the same repo id and path is published by two hosts (e.g. mid ssh-to-runtime migration). - | { kind: 'ambiguous' } - -const NON_RUNTIME_OWNER: WorktreeRuntimeOwner = { kind: 'non-runtime' } -const AMBIGUOUS_OWNER: WorktreeRuntimeOwner = { kind: 'ambiguous' } - -function toRuntimeOwner(environmentId: string | null | undefined): WorktreeRuntimeOwner { - return environmentId ? { kind: 'runtime', environmentId } : NON_RUNTIME_OWNER -} - /** - * @deprecated Use `resolveOwner` (`./resolve-owner`), which keeps the host and reports twins as - * `ambiguous`. Here ambiguous collapses to `null`, which callers read as local; read-only use only. + * The server the pre-`resolveOwner` resolver picked for a row that names no host (`null`: this + * app or a twin). Only {@link resolveOwnerWithLegacyFallback} reads it, after a named host, + * a twin and every stamped row have already been answered. */ -export function getRuntimeEnvironmentIdForWorktree( +function legacyRuntimeEnvironmentId( state: WorktreeRuntimeOwnerState, - worktreeId: string | null | undefined + worktreeId: string ): string | null { - const owner = resolveRuntimeOwnerForWorktree(state, worktreeId) - return owner.kind === 'runtime' ? owner.environmentId : null -} - -/** - * @deprecated Use `resolveOwner` (`./resolve-owner`). This drops the host, reads local, SSH and - * unknown alike as `non-runtime`, and sends folder twins to the focused runtime. - */ -export function resolveRuntimeOwnerForWorktree( - state: WorktreeRuntimeOwnerState, - worktreeId: string | null | undefined -): WorktreeRuntimeOwner { - if (!worktreeId) { - return NON_RUNTIME_OWNER - } - const floatingRoute = getFloatingWorkspaceOperationRoute(worktreeId) - if (floatingRoute) { - return toRuntimeOwner(floatingRoute.runtimeEnvironmentId) - } - const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) - if (activeRoute) { - return toRuntimeOwner(activeRoute.runtimeEnvironmentId) - } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { - return toRuntimeOwner( - getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) - ) + return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) } const indexedOwner = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId) if (indexedOwner.kind === 'ambiguous') { - return AMBIGUOUS_OWNER + return null } if (indexedOwner.kind === 'resolved') { const owner = indexedOwner.owner @@ -121,15 +84,16 @@ export function resolveRuntimeOwnerForWorktree( // Why: a row on a web client that names no server is still reached through its only one. const onlyHost = getPairedWebClientEnvironmentId(state) if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { - return toRuntimeOwner( + return ( projectedRuntimeOwner || - (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) || + null ) } if (!hasDetectedOwner) { const repoResolution = resolveIndexedRepoOwner(state.repos, owner.repoId) if (repoResolution.kind === 'ambiguous') { - return AMBIGUOUS_OWNER + return null } if ( repoResolution.kind === 'resolved' && @@ -137,18 +101,13 @@ export function resolveRuntimeOwnerForWorktree( ) { const repoHost = parseExecutionHostId(getRepoExecutionHostId(repoResolution.owner)) if (repoHost) { - return toRuntimeOwner(repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost) + return (repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost) || null } } } } const resolution = resolveWorktreeOperationRouteResult(state, worktreeId) - if (resolution.kind === 'ambiguous') { - return AMBIGUOUS_OWNER - } - return resolution.kind === 'resolved' - ? toRuntimeOwner(resolution.route.runtimeEnvironmentId) - : NON_RUNTIME_OWNER + return resolution.kind === 'resolved' ? resolution.route.runtimeEnvironmentId || null : null } function getFocusedRuntimeOrLocalExecutionHostId( @@ -237,6 +196,12 @@ function hasRowHost(row: { executionHostId?: string | null; connectionId?: strin return Boolean(row.executionHostId?.trim() || row.connectionId?.trim()) } +/** Through the owner index: rows on two hosts always include a stamped one. */ +function hasStampedRepoRow(state: WorktreeRuntimeOwnerState, repoId: string): boolean { + const repo = resolveIndexedRepoOwner(state.repos, repoId) + return repo.kind === 'ambiguous' || (repo.kind === 'resolved' && hasRowHost(repo.owner)) +} + /** A row that names its host; without one the deprecated resolver's guess still applies. */ function hasHostEvidence(state: WorktreeRuntimeOwnerState, workspaceId: string): boolean { if (getFloatingWorkspaceOperationRoute(workspaceId)) { @@ -245,19 +210,17 @@ function hasHostEvidence(state: WorktreeRuntimeOwnerState, workspaceId: string): const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { const folder = findFolderWorkspaceOwner(state, scope.folderWorkspaceId) - return Boolean( - folder && - (hasRowHost(folder) || - state.projectGroups?.some( - (group) => group.id === folder.projectGroupId && hasRowHost(group) - )) - ) + if (!folder || hasRowHost(folder)) { + return Boolean(folder) + } + const group = resolveIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId) + return group.kind === 'ambiguous' || (group.kind === 'resolved' && hasRowHost(group.owner)) } const repoId = getRepoIdFromWorktreeId(workspaceId) return ( someWorktreeCopy(state, workspaceId, (row) => Boolean(row.hostId?.trim() || row.runtimeOwnerEnvironmentId?.trim()) - ) || Boolean(state.repos?.some((repo) => repo.id === repoId && hasRowHost(repo))) + ) || hasStampedRepoRow(state, repoId) ) } @@ -278,7 +241,7 @@ export function resolveOwnerWithLegacyFallback( ) { return match } - const legacyEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, ref.workspaceId) + const legacyEnvironmentId = legacyRuntimeEnvironmentId(state, ref.workspaceId) if (legacyEnvironmentId) { return { kind: 'resolved', diff --git a/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts b/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts index da9d55bca1d1..6e7fe9bdfd74 100644 --- a/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts +++ b/src/renderer/src/runtime/__fixtures__/web-session-terminal-orphan-recovery-regression-fixtures.ts @@ -2,7 +2,7 @@ import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTerminalClientTab } from '../../../../shared/runtime-types' -import { toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import type { TerminalOrphanRecoveryState } from '../web-session-terminal-orphan-recovery-surface' export const ENVIRONMENT_ID = 'remote-runtime' diff --git a/src/renderer/src/runtime/host-structured-agent-availability.test.ts b/src/renderer/src/runtime/host-structured-agent-availability.test.ts new file mode 100644 index 000000000000..d6ae891dbc14 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agent-availability.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const subscribe = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-subscribe', () => ({ subscribeRuntimeRpc: subscribe })) + +import { + acquireHostStructuredAgentAvailability as acquire, + readHostStructuredAgentAvailability as read +} from './host-structured-agent-availability' + +const unsubscribe = vi.fn() +const owner = { + target: { kind: 'environment', environmentId: 'env' }, + executionHostId: 'runtime:env', + runtimeId: 'r1', + pairingRevision: 1 +} as const +const handlers = (call: number) => subscribe.mock.calls[call][3] + +beforeEach(() => { + subscribe.mockReset().mockResolvedValue({ unsubscribe }) +}) + +it('keeps the snapshot through lost contact and after its last holder, until the host is re-paired', async () => { + const release = acquire(owner) + acquire(owner)() + expect(subscribe).toHaveBeenCalledTimes(1) + expect(subscribe.mock.calls[0][2]).toEqual({}) + expect(read('runtime:env', 'r1', 1)).toEqual({ state: 'pending' }) + const pi = { agent: 'pi', state: 'refused' } + handlers(0).onEvent({ type: 'availability', agents: [pi] }) + const known = { state: 'received', byAgent: new Map([['pi', pi]]) } + + handlers(0).onClose() + expect(read('runtime:env', 'r1', 1)).toEqual(known) + // A reconnect reopens the stream; the snapshot stays until the host replays its own. + acquire(owner) + expect(subscribe).toHaveBeenCalledTimes(2) + expect(read('runtime:env', 'r1', 1)).toEqual(known) + release() + await Promise.resolve() + expect(read('runtime:env', 'r1', 1)).toEqual(known) + + acquire({ ...owner, pairingRevision: 2 }) + expect(read('runtime:env', 'r1', 2)).toEqual({ state: 'pending' }) +}) + +it('distinguishes first-stream failure from an authoritative empty roster, retaining failure during reacquire', () => { + acquire({ ...owner, runtimeId: 'r2' }) + handlers(0).onError(new Error('lost')) + expect(read('runtime:env', 'r2', 1)).toEqual({ state: 'unavailable' }) + acquire({ ...owner, runtimeId: 'r2' }) + expect(read('runtime:env', 'r2', 1)).toEqual({ state: 'unavailable' }) + handlers(1).onEvent({ type: 'availability', agents: [] }) + expect(read('runtime:env', 'r2', 1)).toEqual({ state: 'received', byAgent: new Map() }) +}) diff --git a/src/renderer/src/runtime/host-structured-agent-availability.ts b/src/renderer/src/runtime/host-structured-agent-availability.ts new file mode 100644 index 000000000000..8fbab7b2c4c9 --- /dev/null +++ b/src/renderer/src/runtime/host-structured-agent-availability.ts @@ -0,0 +1,153 @@ +import { + AGENT_SESSION_AVAILABILITY_METHOD, + decodeAgentSessionAvailabilityFrame, + type AgentSessionAgentAvailability +} from '../../../shared/agent-session-agent-availability' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { subscribeRuntimeRpc } from './runtime-rpc-subscribe' + +/** + * This host's agent health snapshot, as its availability stream last published it; every + menu on + * any of its workspaces reads this one projection. Keyed by host, runtime instance and pairing, so a + * restarted or re-paired host reads as not ready until it answers, while a reconnect keeps it. + * + * A received snapshot is kept without readers and through lost contact. A first failure has no + * roster; unlike a received empty frame, it says nothing about which agents this host registered. + */ +export type HostStructuredAgentAvailability = + | { state: 'pending' | 'unavailable' } + | { state: 'received'; byAgent: ReadonlyMap } + +export type HostStructuredAgentAvailabilityOwner = { + target: RuntimeClientTarget + executionHostId: string + /** Null for the local runtime. */ + runtimeId: string | null + pairingRevision: number | undefined +} + +type Resource = { + executionHostId: string + holders: number + model: HostStructuredAgentAvailability + /** Set while a stream is open; a lost one reopens on the next acquire, keeping the model. */ + close: (() => void) | null +} + +const NOT_READY: HostStructuredAgentAvailability = { state: 'pending' } +const resources = new Map() +const listeners = new Set<() => void>() + +function publish(): void { + listeners.forEach((listener) => listener()) +} + +function availabilityKey( + executionHostId: string, + runtimeId: string | null, + pairingRevision: number | undefined +) { + return [executionHostId, runtimeId ?? '', pairingRevision ?? ''].join('\u0000') +} + +function open(key: string, resource: Resource, target: RuntimeClientTarget): void { + let unsubscribe: (() => void) | null = null + const close = (): void => { + resource.close = null + unsubscribe?.() + } + // Only this open's stream, while its entry is current, may publish or be lost. + const live = (): boolean => resources.get(key) === resource && resource.close === close + const settle = (byAgent: ReadonlyMap): void => { + resource.model = { state: 'received', byAgent } + publish() + } + const lose = (): void => { + if (live()) { + close() + if (resource.model.state === 'pending') { + resource.model = { state: 'unavailable' } + publish() + } + } + } + resource.close = close + void subscribeRuntimeRpc( + target, + AGENT_SESSION_AVAILABILITY_METHOD, + {}, + { + onEvent: (frame) => { + const byAgent = live() ? decodeAgentSessionAvailabilityFrame(frame) : null + if (byAgent) { + settle(byAgent) + } + }, + onError: lose, + onClose: lose + } + ).then((handle) => { + if (live()) { + unsubscribe = handle.unsubscribe + } else { + handle.unsubscribe() + } + }, lose) +} + +/** Holds the host's availability stream; the last holder's release closes it and keeps the + * snapshot. Call only for a host advertising the availability capability. */ +export function acquireHostStructuredAgentAvailability( + owner: HostStructuredAgentAvailabilityOwner +): () => void { + const key = availabilityKey(owner.executionHostId, owner.runtimeId, owner.pairingRevision) + let resource = resources.get(key) + if (!resource) { + // A retired runtime or an earlier pairing of this host is never read again. + for (const [staleKey, stale] of resources) { + if (stale.executionHostId === owner.executionHostId && stale.holders === 0) { + resources.delete(staleKey) + } + } + resource = { executionHostId: owner.executionHostId, holders: 0, model: NOT_READY, close: null } + resources.set(key, resource) + } + if (!resource.close) { + open(key, resource, owner.target) + } + const held = resource + held.holders += 1 + let released = false + return () => { + if (released) { + return + } + released = true + held.holders -= 1 + // Why a microtask: a holder that re-acquires in the same commit keeps the stream it has. + queueMicrotask(() => { + if (held.holders === 0) { + held.close?.() + } + }) + } +} + +/** The host's last snapshot on this runtime and pairing; not ready before its first. */ +export function readHostStructuredAgentAvailability( + executionHostId: string, + runtimeId: string | null | undefined, + pairingRevision: number | undefined +): HostStructuredAgentAvailability { + return runtimeId === undefined + ? NOT_READY + : (resources.get(availabilityKey(executionHostId, runtimeId, pairingRevision))?.model ?? + NOT_READY) +} + +export function subscribeHostStructuredAgentAvailability(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/src/renderer/src/runtime/host-structured-agents.test.ts b/src/renderer/src/runtime/host-structured-agents.test.ts index c2d107a19aa5..9aa0631958d9 100644 --- a/src/renderer/src/runtime/host-structured-agents.test.ts +++ b/src/renderer/src/runtime/host-structured-agents.test.ts @@ -55,6 +55,8 @@ vi.mock('@/store', () => ({ import { readHostStructuredAgents, + hostStructuredAgentsReadFailed, + loadHostStructuredAgents, resetHostStructuredAgentsForTests } from './host-structured-agents' import { installHostStructuredAgentsSync } from './host-structured-agents-sync' @@ -211,5 +213,20 @@ describe('host structured agents', () => { await Promise.resolve() expect(agentIds('local')).toBeUndefined() + expect(hostStructuredAgentsReadFailed('local', null)).toBe(true) + }) + + it('keeps a completed failure while explicitly rechecking and clears it only on a valid result', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('host unreachable')) + await loadHostStructuredAgents('local', REGISTERED, null) + expect(hostStructuredAgentsReadFailed('local', null)).toBe(true) + const result = Promise.withResolvers() + mocks.callRuntimeRpc.mockReturnValueOnce(result.promise) + const retry = loadHostStructuredAgents('local', REGISTERED, null) + expect(hostStructuredAgentsReadFailed('local', null)).toBe(true) + result.resolve(GROK_LIST) + await retry + expect(hostStructuredAgentsReadFailed('local', null)).toBe(false) + expect(agentIds('local')).toEqual(['claude', 'grok']) }) }) diff --git a/src/renderer/src/runtime/host-structured-agents.ts b/src/renderer/src/runtime/host-structured-agents.ts index b93743695b51..b28ca153637a 100644 --- a/src/renderer/src/runtime/host-structured-agents.ts +++ b/src/renderer/src/runtime/host-structured-agents.ts @@ -110,7 +110,6 @@ export function loadHostStructuredAgents( if (pending) { return pending } - failed.delete(key) const read = callRuntimeRpc( host.kind === 'local' ? { kind: 'local' } @@ -121,10 +120,12 @@ export function loadHostStructuredAgents( ) .then((result) => { const agents = decodeAgentSessionAgentsResult(result) - if (agents) { - entries.set(executionHostId, { runtimeId, agents }) - publish() + if (!agents) { + throw new Error('Invalid registered-agent response') } + failed.delete(key) + entries.set(executionHostId, { runtimeId, agents }) + publish() }) .catch((error: unknown) => { console.warn('[native-chat] could not read the host structured agents', error) diff --git a/src/renderer/src/runtime/local-runtime-capabilities.test.ts b/src/renderer/src/runtime/local-runtime-capabilities.test.ts index d205e7128674..af673bf91c6a 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.test.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.test.ts @@ -5,6 +5,8 @@ import { ensureLocalRuntimeCapabilities, readLocalRuntimeCapabilities, readLocalRuntimeCapabilitiesOrUnknown, + readLocalRuntimeCapabilityObservation, + subscribeLocalRuntimeCapabilityObservation, refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' @@ -43,6 +45,7 @@ describe('local runtime capabilities', () => { }) it('coalesces concurrent live status reads', async () => { + setLocalRuntimeCapabilitiesForTests(null) let resolve!: (value: { capabilities: string[] }) => void const getStatus = vi.fn( () => new Promise<{ capabilities: string[] }>((next) => (resolve = next)) @@ -51,6 +54,7 @@ describe('local runtime capabilities', () => { const first = refreshLocalRuntimeCapabilities() const second = refreshLocalRuntimeCapabilities() + expect(readLocalRuntimeCapabilityObservation().state).toBe('pending') resolve({ capabilities: ['agent-session.structured.v1'] }) await expect(Promise.all([first, second])).resolves.toEqual([ @@ -62,6 +66,10 @@ describe('local runtime capabilities', () => { }) it('clears stale support when live status becomes unavailable', async () => { + const outcomes: string[] = [] + const stop = subscribeLocalRuntimeCapabilityObservation(() => + outcomes.push(readLocalRuntimeCapabilityObservation().state) + ) setLocalRuntimeCapabilitiesForTests(['agent-session.structured.v1']) Object.assign(window, { api: { @@ -76,12 +84,17 @@ describe('local runtime capabilities', () => { await expect(refreshLocalRuntimeCapabilities()).resolves.toEqual([]) expect(readLocalRuntimeCapabilities()).toEqual([]) expect(readLocalRuntimeCapabilitiesOrUnknown()).toBeNull() + expect(readLocalRuntimeCapabilityObservation().state).toBe('unavailable') window.api.runtime.getStatus = vi .fn() .mockResolvedValue({ capabilities: ['agent-session.structured.v1'] }) - await refreshLocalRuntimeCapabilities() + const retry = refreshLocalRuntimeCapabilities() + expect(readLocalRuntimeCapabilityObservation().state).toBe('unavailable') + await retry expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual(['agent-session.structured.v1']) + expect(outcomes).toEqual(['unavailable', 'known']) + stop() }) it('ensure probes the runtime when no answer has landed yet', async () => { setLocalRuntimeCapabilitiesForTests(null) diff --git a/src/renderer/src/runtime/local-runtime-capabilities.ts b/src/renderer/src/runtime/local-runtime-capabilities.ts index 39ff41d84ffc..38cc7f1faf5b 100644 --- a/src/renderer/src/runtime/local-runtime-capabilities.ts +++ b/src/renderer/src/runtime/local-runtime-capabilities.ts @@ -1,10 +1,28 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' -// `null` while no successful probe has landed. "Not asked yet" and "host says no" are -// different answers, and a caller that routes on them must be able to tell them apart. -let localRuntimeCapabilities: readonly RuntimeCapability[] | null = null +type LocalRuntimeCapabilityObservation = + | { state: 'unlearned' | 'pending' | 'unavailable' } + | { state: 'known'; capabilities: readonly RuntimeCapability[] } +let observation: LocalRuntimeCapabilityObservation = { state: 'unlearned' } let refreshPromise: Promise | null = null const knownListeners = new Set<(capabilities: readonly RuntimeCapability[]) => void>() +const listeners = new Set<() => void>() + +export function readLocalRuntimeCapabilityObservation(): LocalRuntimeCapabilityObservation { + return observation +} + +export function subscribeLocalRuntimeCapabilityObservation(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function publish(next: LocalRuntimeCapabilityObservation): void { + observation = next + listeners.forEach((listener) => listener()) +} /** Called each time a probe lands an answer, so work that needs the capabilities runs when they * become known instead of being skipped while they were not. Returns the unsubscribe. */ @@ -18,13 +36,13 @@ export function subscribeLocalRuntimeCapabilitiesKnown( } export function readLocalRuntimeCapabilities(): readonly RuntimeCapability[] { - return localRuntimeCapabilities ?? [] + return readLocalRuntimeCapabilitiesOrUnknown() ?? [] } /** `null` when the local runtime has not answered yet, so a routing decision can wait * instead of reading an unprobed host as unsupported. */ export function readLocalRuntimeCapabilitiesOrUnknown(): readonly RuntimeCapability[] | null { - return localRuntimeCapabilities + return observation.state === 'known' ? observation.capabilities : null } /** Like `readLocalRuntimeCapabilitiesOrUnknown`, but probes the local runtime when no answer @@ -39,11 +57,12 @@ export function readLocalRuntimeCapabilitiesOrUnknown(): readonly RuntimeCapabil export async function ensureLocalRuntimeCapabilities(): Promise< readonly RuntimeCapability[] | null > { - if (localRuntimeCapabilities !== null) { - return localRuntimeCapabilities + const known = readLocalRuntimeCapabilitiesOrUnknown() + if (known !== null) { + return known } await refreshLocalRuntimeCapabilities() - return localRuntimeCapabilities + return readLocalRuntimeCapabilitiesOrUnknown() } /** Resolves with the capabilities once a probe lands them, probing once now; a failed probe does @@ -54,8 +73,9 @@ export function awaitLocalRuntimeCapabilities(): { } { let stop = (): void => {} const known = new Promise((resolve) => { - if (localRuntimeCapabilities !== null) { - resolve(localRuntimeCapabilities) + const capabilities = readLocalRuntimeCapabilitiesOrUnknown() + if (capabilities !== null) { + resolve(capabilities) return } stop = subscribeLocalRuntimeCapabilitiesKnown((capabilities) => { @@ -81,25 +101,31 @@ export function refreshLocalRuntimeCapabilities(): Promise { const capabilities = [...(status.capabilities ?? [])] - localRuntimeCapabilities = capabilities + publish({ state: 'known', capabilities }) knownListeners.forEach((listener) => listener(capabilities)) return capabilities }) .catch(() => { // Stays unknown rather than becoming an empty (== unsupported) list: a failed probe // is not evidence about the host. - localRuntimeCapabilities = null + publish({ state: 'unavailable' }) return [] }) .finally(() => { refreshPromise = null }) + if (observation.state === 'unlearned') { + publish({ state: 'pending' }) + } return refreshPromise } export function setLocalRuntimeCapabilitiesForTests( capabilities: readonly RuntimeCapability[] | null ): void { - localRuntimeCapabilities = capabilities === null ? null : [...capabilities] + observation = + capabilities === null + ? { state: 'unlearned' } + : { state: 'known', capabilities: [...capabilities] } refreshPromise = null } diff --git a/src/renderer/src/runtime/remote-agent-session-launch.test.ts b/src/renderer/src/runtime/remote-agent-session-launch.test.ts index 4b828d3b475b..ab7e0380b80e 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.test.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.test.ts @@ -34,6 +34,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent: 'claude', hostAuthority, hostAuthorityCapability: 'agent-session.omp-resume-path.v1', legacy @@ -59,6 +60,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent, hostAuthority, hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), legacy @@ -82,6 +84,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent, hostAuthority, hostAuthorityCapability: agentResumeHostAuthorityCapability(agent), legacy @@ -102,7 +105,12 @@ describe('remote agent-session launch routing', () => { mocks.supportsCapability.mockResolvedValue(false) await expect( - runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent: 'claude', + hostAuthority, + legacy + }) ).resolves.toBe('legacy') expect(legacy).toHaveBeenCalledOnce() expect(hostAuthority).not.toHaveBeenCalled() @@ -114,7 +122,12 @@ describe('remote agent-session launch routing', () => { mocks.supportsCapability.mockRejectedValue(new Error('status temporarily unavailable')) await expect( - runRemoteAgentSessionLaunch({ environmentId: 'env-1', hostAuthority, legacy }) + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent: 'claude', + hostAuthority, + legacy + }) ).resolves.toBe('legacy') expect(legacy).toHaveBeenCalledOnce() expect(hostAuthority).not.toHaveBeenCalled() @@ -130,6 +143,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent: 'claude', hostAuthority: vi.fn(), legacy }) @@ -145,6 +159,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent: 'claude', hostAuthority: vi.fn().mockRejectedValue(structuredError), legacy }) @@ -164,6 +179,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent: 'claude', hostAuthority: vi.fn().mockRejectedValue(legacyRequired), legacy }) @@ -183,6 +199,7 @@ describe('remote agent-session launch routing', () => { await expect( runRemoteAgentSessionLaunch({ environmentId: 'env-1', + agent: 'claude', hostAuthority: vi.fn().mockRejectedValue(methodNotFound), legacy }) @@ -193,9 +210,87 @@ describe('remote agent-session launch routing', () => { it('uses legacy directly when no structured form exists', async () => { const legacy = vi.fn().mockResolvedValue('legacy') - await expect(runRemoteAgentSessionLaunch({ environmentId: 'env-1', legacy })).resolves.toBe( - 'legacy' - ) + await expect( + runRemoteAgentSessionLaunch({ environmentId: 'env-1', agent: 'claude', legacy }) + ).resolves.toBe('legacy') expect(mocks.supportsCapability).not.toHaveBeenCalled() }) + + it.each([ + { effort: 'off', hostAuthority: true }, + { effort: 'auto', hostAuthority: true }, + { effort: 'off', hostAuthority: false }, + { effort: 'auto', hostAuthority: false } + ])( + 'refuses unsupported OMP $effort before either launch path ($hostAuthority)', + async ({ effort, hostAuthority: hasHostAuthority }) => { + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockImplementation( + (_environmentId, capability) => capability === 'agent-session.host-authority.v1' + ) + + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent: 'omp', + launchPreferences: { model: 'anthropic/claude-opus-5-5', effort }, + ...(hasHostAuthority ? { hostAuthority } : {}), + legacy + }) + ).rejects.toThrow('cannot preserve OMP launch thinking') + expect(hostAuthority).not.toHaveBeenCalled() + expect(legacy).not.toHaveBeenCalled() + } + ) + + it('does not launch OMP with explicit thinking after a failed capability probe', async () => { + const unavailable = new Error('Host unavailable') + mocks.supportsCapability.mockRejectedValue(unavailable) + const legacy = vi.fn() + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent: 'omp', + launchPreferences: { model: 'opus', effort: 'off' }, + legacy + }) + ).rejects.toBe(unavailable) + expect(legacy).not.toHaveBeenCalled() + }) + + it('launches explicit OMP thinking when its execution host supports it', async () => { + mocks.supportsCapability.mockResolvedValue(true) + const hostAuthority = vi.fn().mockResolvedValue('host') + const legacy = vi.fn() + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent: 'omp', + launchPreferences: { model: 'opus', effort: 'off' }, + hostAuthority, + legacy + }) + ).resolves.toBe('host') + expect(legacy).not.toHaveBeenCalled() + }) + + it.each([ + { agent: 'omp', launchPreferences: { model: 'opus' } }, + { agent: 'claude', launchPreferences: { model: 'opus', effort: 'high' } } + ] as const)( + 'preserves legacy compatibility for $agent without explicit OMP thinking', + async ({ agent, launchPreferences }) => { + const legacy = vi.fn().mockResolvedValue('legacy') + mocks.supportsCapability.mockResolvedValue(false) + await expect( + runRemoteAgentSessionLaunch({ + environmentId: 'env-1', + agent, + launchPreferences, + legacy + }) + ).resolves.toBe('legacy') + } + ) }) diff --git a/src/renderer/src/runtime/remote-agent-session-launch.ts b/src/renderer/src/runtime/remote-agent-session-launch.ts index b85d104b399a..f235502fb735 100644 --- a/src/renderer/src/runtime/remote-agent-session-launch.ts +++ b/src/renderer/src/runtime/remote-agent-session-launch.ts @@ -1,15 +1,40 @@ +import type { AgentLaunchPreferences } from '../../../shared/agent-session-host-authority' +import { AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' +import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from '../../../shared/agent-session-host-authority' import type { RuntimeCapability } from '../../../shared/protocol-version' import { RuntimeRpcCallError } from './runtime-rpc-result' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import { isRuntimeCompatBlockError } from './runtime-protocol-compat' +export async function assertRemoteAgentThinkingSupported(args: { + environmentId: string + agent: TuiAgent + launchPreferences?: AgentLaunchPreferences +}): Promise { + if ( + args.agent === 'omp' && + args.launchPreferences?.effort !== undefined && + !(await runtimeEnvironmentSupportsCapability( + args.environmentId, + AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY + )) + ) { + throw new Error( + 'This Orca host cannot preserve OMP launch thinking. Update Orca on the execution host, or omit the thinking preference.' + ) + } +} + export async function runRemoteAgentSessionLaunch(args: { environmentId: string + agent: TuiAgent + launchPreferences?: AgentLaunchPreferences hostAuthority?: () => Promise hostAuthorityCapability?: RuntimeCapability legacy: (options: { skipCompatibilityCheck: boolean }) => Promise }): Promise { + await assertRemoteAgentThinkingSupported(args) if (!args.hostAuthority) { return await args.legacy({ skipCompatibilityCheck: false }) } diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index 3e5e71c304dd..6307e3140a6c 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -14,6 +14,13 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' import { REMOTE_RUNTIME_MAX_FILE_WRITE_CONTENT_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { + FILE_WRITE_OK, + parseFileWriteResult, + type FileWriteDiskPrecondition, + type FileWriteResult +} from '../../../shared/file-write-disk-precondition' +import { writeRemoteFileIfUnchanged } from './runtime-file-write-disk-precondition' /** Refuses a save the host's frame cap would reject by dropping the whole connection. */ function assertRemoteWriteFitsOneRequest(content: string): void { @@ -47,35 +54,45 @@ export async function readRuntimeDirectory( ) } +/** With `diskPrecondition`, a write whose target no longer matches the baseline is refused, not written. */ export async function writeRuntimeFile( context: RuntimeFileOperationArgs, filePath: string, content: string, - access?: LocalFileAccess -): Promise { + access?: LocalFileAccess, + diskPrecondition?: FileWriteDiskPrecondition +): Promise { const remoteArgs = getRemoteFileArgs(context, filePath) if (!remoteArgs) { assertLocalFilesystemFallbackAllowed(context) - await window.api.fs.writeFile( - withSshMutationExpectation(context, { - filePath, - content, - connectionId: context.connectionId, - ...localAccess(context.connectionId, access) - }) + return parseFileWriteResult( + await window.api.fs.writeFile( + withSshMutationExpectation(context, { + filePath, + content, + connectionId: context.connectionId, + ...localAccess(context.connectionId, access), + ...(diskPrecondition ? { diskPrecondition } : {}) + }) + ) ) - return } assertRemoteWriteFitsOneRequest(content) - await callRuntimeFileMutation( - remoteArgs.target, - 'files.write', - withSshMutationExpectation(context, { - worktree: remoteArgs.worktreeSelector, - relativePath: remoteArgs.relativePath, - content - }), - 15_000 + const params = withSshMutationExpectation(context, { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + content + }) + if (!diskPrecondition) { + await callRuntimeFileMutation(remoteArgs.target, 'files.write', params, 15_000) + return FILE_WRITE_OK + } + return writeRemoteFileIfUnchanged( + { ...context, target: remoteArgs.target }, + filePath, + content, + params, + diskPrecondition ) } diff --git a/src/renderer/src/runtime/runtime-file-mutation-rpc.ts b/src/renderer/src/runtime/runtime-file-mutation-rpc.ts index b6d56a7c47d9..9de40f3e2118 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-rpc.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-rpc.ts @@ -1,6 +1,8 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' +import { RUNTIME_CONNECTION_CHANGED_MESSAGE } from '../web/web-runtime-request-registry' import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' import { callRuntimeRpc } from './runtime-rpc-client' +import { RuntimeRpcCallError } from './runtime-rpc-result' import { captureRuntimeEnvironmentRequestRevision, getRuntimeEnvironmentRevision @@ -16,16 +18,83 @@ export type RuntimeFileImportSession = { assertCurrent: () => void } -export async function assertRuntimeFileMutationCapability( +async function getRuntimeFileMutationStatus( target: RuntimeFileMutationTarget, expectedEnvironmentPairingRevision: number | undefined -): Promise { +): Promise { const status = await callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000, expectedEnvironmentPairingRevision }) assertFileMutationOwnershipCapability(status) - return status.runtimeId + return status +} + +export async function assertRuntimeFileMutationCapability( + target: RuntimeFileMutationTarget, + expectedEnvironmentPairingRevision: number | undefined +): Promise { + return (await getRuntimeFileMutationStatus(target, expectedEnvironmentPairingRevision)).runtimeId +} + +/** Thrown before sending, so nothing reached either runtime and a re-probed retry is safe. */ +export class RuntimeConnectionChangedError extends Error { + constructor() { + super(RUNTIME_CONNECTION_CHANGED_MESSAGE) + } +} + +/** + * The paired web client's transport refuses with the same wording before it sends; the desktop + * transport answers `runtime_environment_changed` only before sending, when its socket reached + * another runtime than the one probed. + */ +export function isRuntimeConnectionChangedError(error: unknown): boolean { + return ( + error instanceof RuntimeConnectionChangedError || + (error instanceof RuntimeRpcCallError && error.code === 'runtime_environment_changed') || + (error instanceof Error && error.message === RUNTIME_CONNECTION_CHANGED_MESSAGE) + ) +} + +export type RuntimeFileMutationPlan = + | { method?: string; params: unknown } + | { result: TResult } + +/** + * `plan` sees the ownership probe's capabilities, so a capability-gated field costs no extra status + * call. The send is pinned to that runtime and connection: a reconnect to an older one in between + * would silently strip whatever the plan relied on. + */ +export async function callRuntimeFileMutationWithCapabilities( + target: RuntimeFileMutationTarget, + method: string, + plan: (capabilities: readonly string[]) => Promise>, + timeoutMs: number, + expectedEnvironmentPairingRevision?: number +): Promise { + const requestRevision = captureRuntimeEnvironmentRequestRevision( + target.environmentId, + expectedEnvironmentPairingRevision + ) + const connectionGeneration = getRuntimeEnvironmentConnectionGeneration(target.environmentId) + const status = await getRuntimeFileMutationStatus(target, requestRevision) + const planned = await plan(status.capabilities ?? []) + if ('result' in planned) { + return planned.result + } + return callRuntimeRpc(target, planned.method ?? method, planned.params, { + timeoutMs, + expectedEnvironmentPairingRevision: requestRevision, + expectedEnvironmentRuntimeId: status.runtimeId, + beforeSend: () => { + if ( + getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== connectionGeneration + ) { + throw new RuntimeConnectionChangedError() + } + } + }) } export async function callRuntimeFileMutation( @@ -39,7 +108,7 @@ export async function callRuntimeFileMutation( target.environmentId, expectedEnvironmentPairingRevision ) - await assertRuntimeFileMutationCapability(target, requestRevision) + await getRuntimeFileMutationStatus(target, requestRevision) return callRuntimeRpc(target, method, params, { timeoutMs, expectedEnvironmentPairingRevision: requestRevision diff --git a/src/renderer/src/runtime/runtime-file-write-disk-precondition.test.ts b/src/renderer/src/runtime/runtime-file-write-disk-precondition.test.ts new file mode 100644 index 000000000000..3d5197c78c3d --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-write-disk-precondition.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from 'vitest' +import { writeRuntimeFile } from './runtime-file-mutation-client' +import { + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION +} from '../../../shared/protocol-version' +import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from '../../../shared/file-write-disk-precondition' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' +import { advanceRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status-connection-generation' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { + fsWriteFile, + installRuntimeFileClientEnvironment, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall, + type RuntimeRpcRequest +} from './runtime-file-client-test-harness' + +installRuntimeFileClientEnvironment() + +const remoteContext = { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' +} +const precondition = { expectedSignature: getDiskBaselineSignature('original') } +const ENOENT_MESSAGE = "ENOENT: no such file or directory, stat '/remote/repo/file.ts'" + +function rpcOk(result: unknown) { + return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'remote-runtime' } } +} + +function serveHost(options: { + enforcesPrecondition: boolean + diskContent?: string + readError?: string + readErrorCode?: string + onRead?: () => void + onStatus?: () => void +}): void { + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeRpcRequest) => { + if (args.method === 'status.get') { + options.onStatus?.() + return Promise.resolve( + rpcOk({ + runtimeId: 'remote-runtime', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [ + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + ...(options.enforcesPrecondition + ? [FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY] + : []) + ] + }) + ) + } + if (args.method === 'files.read') { + options.onRead?.() + return Promise.resolve( + options.diskContent === undefined + ? { + id: 'rpc', + ok: false, + error: { + code: options.readErrorCode ?? 'runtime_error', + message: options.readError ?? ENOENT_MESSAGE + } + } + : rpcOk({ content: options.diskContent, truncated: false, byteLength: 0 }) + ) + } + return runtimeEnvironmentCall(args) + }) +} + +function writeCalls(): RuntimeRpcRequest[] { + return runtimeEnvironmentCall.mock.calls + .map(([args]) => args) + .filter((args) => args.method === 'files.write' || args.method === 'files.writeIfUnchanged') +} + +describe('writeRuntimeFile disk precondition', () => { + it('sends the precondition to a local writer and returns its refusal', async () => { + fsWriteFile.mockResolvedValue({ ok: false, diskConflict: 'changed' }) + + const result = await writeRuntimeFile( + { target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo' }, + '/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(fsWriteFile).toHaveBeenCalledWith( + expect.objectContaining({ diskPrecondition: precondition }) + ) + }) + + it('cannot write through an older server reached after a supporting server was probed', async () => { + // The shared socket reconnected to an older server between the capability verdict and the send. + serveHost({ enforcesPrecondition: true }) + const unguardedWrites: RuntimeRpcRequest[] = [] + runtimeEnvironmentCall.mockImplementation((args: RuntimeRpcRequest) => { + if (args.method === 'files.write') { + unguardedWrites.push(args) + return Promise.resolve(rpcOk({ ok: true })) + } + return Promise.resolve({ + id: 'rpc', + ok: false, + error: { code: 'method_not_found', message: `Unknown method: ${args.method}` }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + ).rejects.toThrow() + expect(unguardedWrites).toHaveLength(0) + }) + + it('lets a host that enforces the precondition compare at write time', async () => { + serveHost({ enforcesPrecondition: true }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: false, diskConflict: 'missing' })) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: false, diskConflict: 'missing' }) + expect(writeCalls()).toHaveLength(1) + expect(writeCalls()[0]?.params).toMatchObject({ diskPrecondition: precondition }) + expect(runtimeEnvironmentTransportCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.read' }) + ) + }) + + it('compares from the client before writing through an older host', async () => { + serveHost({ enforcesPrecondition: false, diskContent: 'original' }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: true }) + expect(writeCalls()).toHaveLength(1) + expect(writeCalls()[0]?.params).not.toHaveProperty('diskPrecondition') + }) + + it("decides support from the write path's own status probe, one per save", async () => { + serveHost({ enforcesPrecondition: false, diskContent: 'original' }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + + for (let save = 0; save < 3; save += 1) { + await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + } + + const statusCalls = runtimeEnvironmentTransportCall.mock.calls.filter( + ([args]) => args.method === 'status.get' + ) + // One ownership probe per save plus the read client's one-time compatibility probe. + expect(statusCalls).toHaveLength(4) + expect(writeCalls()).toHaveLength(3) + }) + + it('refuses without writing when an older host holds newer disk content', async () => { + serveHost({ enforcesPrecondition: false, diskContent: 'agent rewrite' }) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(writeCalls()).toHaveLength(0) + }) + + it('reports a confirmed-missing file to an autosave through an older host', async () => { + serveHost({ enforcesPrecondition: false }) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: false, diskConflict: 'missing' }) + expect(writeCalls()).toHaveLength(0) + }) + + it('lets an explicit save recreate a confirmed-missing file through an older host', async () => { + serveHost({ enforcesPrecondition: false }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + { + ...precondition, + allowMissing: true + } + ) + + expect(result).toEqual({ ok: true }) + expect(writeCalls()).toHaveLength(1) + }) + + it('fails an explicit save, without writing, when an older host read fails for another reason', async () => { + serveHost({ enforcesPrecondition: false, readError: 'Runtime request timed out' }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, { + ...precondition, + allowMissing: true + }) + ).rejects.toThrow('timed out') + expect(writeCalls()).toHaveLength(0) + }) + + it('reports a file an older host can no longer send as changed', async () => { + serveHost({ + enforcesPrecondition: false, + readError: 'File too large: 60.0MB exceeds 50MB limit' + }) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + { + ...precondition, + allowMissing: true + } + ) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(writeCalls()).toHaveLength(0) + }) + + it('binds a guarded write to the runtime whose capabilities were probed', async () => { + serveHost({ enforcesPrecondition: true }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + + await writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + + expect(writeCalls()[0]).toMatchObject({ expectedEnvironmentRuntimeId: 'remote-runtime' }) + }) + + it('refuses to send when the runtime connection changes after the probe', async () => { + serveHost({ + enforcesPrecondition: false, + diskContent: 'original', + onRead: () => advanceRuntimeEnvironmentConnectionGeneration('env-1') + }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + ).rejects.toThrow('Runtime connection changed') + expect(writeCalls()).toHaveLength(0) + }) + + it('reports the wire file_too_large refusal from an older host as changed', async () => { + serveHost({ + enforcesPrecondition: false, + readErrorCode: 'file_too_large', + readError: 'file_too_large' + }) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + { + ...precondition, + allowMissing: true + } + ) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(writeCalls()).toHaveLength(0) + }) + + it('keeps the save when the guarded write landed but its reply timed out', async () => { + serveHost({ enforcesPrecondition: true, diskContent: 'edited' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc', + ok: false, + error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote Orca runtime' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: true }) + }) + + it('does not take a re-paired host holding the text as proof a lost write landed', async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 1 }]) + serveHost({ enforcesPrecondition: true, diskContent: 'edited' }) + runtimeEnvironmentCall.mockImplementation(async () => { + // The reply is lost, and the environment id is re-paired to another machine meanwhile. + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 2 }]) + return { + id: 'rpc', + ok: false, + error: { + code: 'runtime_timeout', + message: 'Timed out waiting for the remote Orca runtime' + }, + _meta: { runtimeId: 'remote-runtime' } + } + }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + ).rejects.toThrow('Timed out') + }) + + it('still fails a timed-out guarded write whose content is not on disk', async () => { + serveHost({ enforcesPrecondition: true, diskContent: 'original' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc', + ok: false, + error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote Orca runtime' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + ).rejects.toThrow('Timed out') + }) + + it('retries once on the new connection when it changed before the send', async () => { + let probes = 0 + serveHost({ + enforcesPrecondition: true, + onStatus: () => { + probes += 1 + if (probes === 1) { + // The save started while disconnected; the transport reconnects during the probe. + advanceRuntimeEnvironmentConnectionGeneration('env-1') + } + } + }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: true }) + expect(writeCalls()).toHaveLength(1) + }) + + it('re-probes once when the transport refused to send to a replacement runtime', async () => { + let probes = 0 + serveHost({ enforcesPrecondition: true, onStatus: () => (probes += 1) }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'files.writeIfUnchanged', + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment identity changed; refresh and try again' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValue(rpcOk({ ok: true })) + + const result = await writeRuntimeFile( + remoteContext, + '/remote/repo/file.ts', + 'edited', + undefined, + precondition + ) + + expect(result).toEqual({ ok: true }) + // The retry decides support from a fresh probe of whatever now answers. + expect(probes).toBeGreaterThanOrEqual(2) + expect(writeCalls()).toHaveLength(2) + }) + + it('never retries onto a host the environment was re-paired to mid-save', async () => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 1 }]) + serveHost({ enforcesPrecondition: true }) + runtimeEnvironmentCall.mockResolvedValue(rpcOk({ ok: true })) + runtimeEnvironmentCall.mockImplementationOnce(async () => { + // The user re-pairs the environment id to another machine while the first send is refused. + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 2 }]) + return { + id: 'files.writeIfUnchanged', + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: 'remote-runtime' } + } + }) + + await expect( + writeRuntimeFile(remoteContext, '/remote/repo/file.ts', 'edited', undefined, precondition) + ).rejects.toThrow('pairing changed') + expect(writeCalls()).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/runtime/runtime-file-write-disk-precondition.ts b/src/renderer/src/runtime/runtime-file-write-disk-precondition.ts new file mode 100644 index 000000000000..2ae14aca5271 --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-write-disk-precondition.ts @@ -0,0 +1,158 @@ +import { + compareDiskWritePrecondition, + diskConflictFromReadFailure, + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, + FILE_WRITE_IF_UNCHANGED_METHOD, + FILE_WRITE_OK, + parseFileWriteResult, + type FileWriteDiskConflict, + type FileWriteDiskPrecondition, + type FileWriteResult +} from '../../../shared/file-write-disk-precondition' +import { + getDiskBaselineSignature, + getWrittenContentDiskSignature +} from '../../../shared/disk-baseline-signature' +import { + isRecoverableRemoteRuntimeConnectionError, + toRemoteRuntimeClientErrorLike +} from '../../../shared/remote-runtime-client-error-classification' +import { + callRuntimeFileMutationWithCapabilities, + isRuntimeConnectionChangedError +} from './runtime-file-mutation-rpc' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import { readRuntimeFileContent } from './runtime-file-read-client' +import { + captureRuntimeEnvironmentRequestRevision, + getRuntimeEnvironmentRevision +} from './runtime-environment-revision' +import { getRelativePathInsideWorktree } from './runtime-file-routing' +import type { RuntimeFileOperationArgs } from './runtime-file-client-types' + +/** `files.write` params; the guarded method adds `diskPrecondition`. */ +export type RemoteFileWriteParams = { + worktree: string + relativePath: string + content: string + expectedExecutionHostId: 'local' | `ssh:${string}` + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number +} + +type RemoteFileContext = RuntimeFileOperationArgs & { + target: RuntimeClientTarget & { kind: 'environment' } +} + +function readRemoteFile( + context: RemoteFileContext, + filePath: string +): Promise<{ content: string; isBinary: boolean }> { + return readRuntimeFileContent({ + target: context.target, + filePath, + relativePath: getRelativePathInsideWorktree(context.worktreePath, filePath) ?? undefined, + worktreeId: context.worktreeId ?? undefined, + connectionId: context.connectionId, + expectedExternalSshTargetId: context.expectedExternalSshTargetId + }) +} + +async function diskHoldsWrittenContent( + context: RemoteFileContext, + filePath: string, + content: string, + pairingRevision: number | undefined +): Promise { + // Why the save's pairing: a host the environment was re-paired to proves nothing about this write. + const samePairing = (): boolean => + getRuntimeEnvironmentRevision(context.target.environmentId) === pairingRevision + try { + if (!samePairing()) { + return false + } + const current = await readRemoteFile(context, filePath) + return ( + samePairing() && + !current.isBinary && + getDiskBaselineSignature(current.content) === getWrittenContentDiskSignature(content) + ) + } catch { + return false + } +} + +/** + * A guarded remote save. The host compares at write time when it serves `files.writeIfUnchanged`; + * an older host gets a client-side compare before a plain write. + */ +export async function writeRemoteFileIfUnchanged( + context: RemoteFileContext, + filePath: string, + content: string, + params: RemoteFileWriteParams, + precondition: FileWriteDiskPrecondition +): Promise { + // Why once per save: a retry may follow a reconnect, never a re-pair to another host. + const pairingRevision = captureRuntimeEnvironmentRequestRevision(context.target.environmentId) + const attempt = async (): Promise => + parseFileWriteResult( + await callRuntimeFileMutationWithCapabilities( + context.target, + 'files.write', + async (capabilities) => { + if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) { + return { + method: FILE_WRITE_IF_UNCHANGED_METHOD, + params: { ...params, diskPrecondition: precondition } + } + } + const diskConflict = await checkDiskPreconditionFromClient( + context, + filePath, + precondition + ) + return diskConflict ? { result: { ok: false, diskConflict } } : { params } + }, + 15_000, + pairingRevision + ) + ) + try { + return await attempt() + } catch (error) { + if ( + isRuntimeConnectionChangedError(error) && + getRuntimeEnvironmentRevision(context.target.environmentId) === pairingRevision + ) { + // Why: nothing was sent; one retry re-probes the new connection's capabilities. + return attempt() + } + // Why: a reply lost to the transport may follow a write that landed; keeping it avoids refusing the next save. + if ( + isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) && + (await diskHoldsWrittenContent(context, filePath, content, pairingRevision)) + ) { + return FILE_WRITE_OK + } + throw error + } +} + +/** + * For a host that would strip the precondition: compare from here before the unguarded write. + * A read failure that doesn't prove the disk state fails the save rather than writing unverified. + */ +async function checkDiskPreconditionFromClient( + context: RemoteFileContext, + filePath: string, + precondition: FileWriteDiskPrecondition +): Promise { + let current: { content: string; isBinary: boolean } + try { + current = await readRemoteFile(context, filePath) + } catch (error) { + return diskConflictFromReadFailure(precondition, error) + } + return compareDiskWritePrecondition(precondition, current.isBinary ? undefined : current.content) +} diff --git a/src/renderer/src/runtime/runtime-paged-file-read.ts b/src/renderer/src/runtime/runtime-paged-file-read.ts index 7f005ed1a1d2..42946f322ea1 100644 --- a/src/renderer/src/runtime/runtime-paged-file-read.ts +++ b/src/renderer/src/runtime/runtime-paged-file-read.ts @@ -1,10 +1,14 @@ import { basename } from '@/lib/path' import { BINARY_PROBE_BYTES } from '../../../shared/binary-buffer' import { IMAGE_FILE_MIME_TYPES } from '../../../shared/image-file-extensions' -import type { RuntimeFilePreviewResult } from '../../../shared/runtime-types' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadChunkResult +} from '../../../shared/runtime-types' import { readRemoteRuntimeFileChunk, statRemoteRuntimeFile, + type RuntimeFileSnapshot, type RuntimeRemoteFileAddress } from './runtime-file-range-client' import { RuntimeRpcCallError } from './runtime-rpc-result' @@ -44,7 +48,21 @@ export async function readRemoteFileInChunks( address: RuntimeRemoteFileAddress ): Promise { const remote = pinRemoteFileAddress(address) - const before = await nullWhenUnsupported(() => statRemoteRuntimeFile(remote)) + return readFileInChunks({ + stat: () => statRemoteRuntimeFile(remote), + readChunk: (offset, length) => readRemoteRuntimeFileChunk(remote, offset, length) + }) +} + +/** Host calls for one file; a paired web client binds them to its own session. */ +export type ChunkedFileReader = { + stat: () => Promise + readChunk: (offset: number, length: number) => Promise +} + +/** The paging loop behind `readRemoteFileInChunks`, over any way of calling the host. */ +export async function readFileInChunks(reader: ChunkedFileReader): Promise { + const before = await nullWhenUnsupported(() => reader.stat()) if (!before) { return { kind: 'unsupported' } } @@ -57,9 +75,7 @@ export async function readRemoteFileInChunks( const bytes = new Uint8Array(before.size) let offset = 0 for (;;) { - const chunk = await nullWhenUnsupported(() => - readRemoteRuntimeFileChunk(remote, offset, PAGED_READ_CHUNK_BYTES) - ) + const chunk = await nullWhenUnsupported(() => reader.readChunk(offset, PAGED_READ_CHUNK_BYTES)) if (!chunk) { return { kind: 'unsupported' } } @@ -76,7 +92,7 @@ export async function readRemoteFileInChunks( } // Why: chunks are separate reads, so a rewrite between them (a PDF rebuild) would splice two // versions into one corrupt file. - const after = await statRemoteRuntimeFile(remote) + const after = await reader.stat() if ( offset !== before.size || after.size !== before.size || diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index c32f42317319..00078b61038b 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -1,10 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - inspectRuntimeTerminalProcess, - recordRuntimeTerminalInputForPtyId, - sendRuntimePtyInput, - sendRuntimePtyInputVerified -} from './runtime-terminal-inspection' +import { inspectRuntimeTerminalProcess, sendRuntimePtyInput } from './runtime-terminal-inspection' +import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' +import { sendRuntimePtyInputVerified } from './runtime-terminal-verified-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' import { createCompatibleRuntimeStatusResponseIfNeeded, diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index c6435187fc25..808f92021f43 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -1,7 +1,6 @@ import type { RuntimeTerminalSend } from '../../../shared/runtime-types' import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input' import { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' -export { recordRuntimeTerminalInputForPtyId } from './runtime-terminal-input-recording' import { callRuntimeRpc } from './runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, @@ -16,10 +15,6 @@ import { type TerminalProcessInspection } from '../../../shared/terminal-process-inspection' -export type { - ClientOnlyUnverifiableInspection, - ClientOnlyUnverifiableReason -} from '../../../shared/terminal-process-inspection' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' export type RuntimeTerminalProcessInspection = TerminalProcessInspection @@ -29,8 +24,6 @@ function isRuntimePtyInputTooLarge(data: string): boolean | Promise { return isTerminalInputTooLargeWithDeferredMeasurement(data) } -export { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' - function isRemoteInspectionPtyId(ptyId: string): boolean { return getRemoteRuntimePtyEnvironmentId(ptyId) !== null || parseAppSshPtyId(ptyId) !== null } @@ -190,5 +183,3 @@ function sendRuntimePtyInputWithinLimit( }) return true } - -export { sendRuntimePtyInputVerified } from './runtime-terminal-verified-input' diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index a716121244f4..3d30f33420aa 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -14,9 +14,9 @@ import { import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle, - subscribeToRuntimeTerminalData, - toRemoteRuntimePtyId + subscribeToRuntimeTerminalData } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' describe('remote runtime terminal ids', () => { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index f6a2e827b0fc..3c96b4f94112 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -2,12 +2,6 @@ import { RuntimeRpcCallError } from './runtime-rpc-result' import { getRemoteRuntimeTerminalMultiplexer } from './remote-runtime-terminal-multiplexer' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' -export { - parseRemoteRuntimePtyId, - toRemoteRuntimePtyId, - type RemoteRuntimePtyIdParts -} from '../../../shared/remote-runtime-pty-id' - const LIVE_TAIL_SUBSCRIPTION_TIMEOUT_MS = 10_000 export type RuntimeTerminalDataSubscriptionOptions = { diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 06b4d6ff9f8d..7ad8cff68ac2 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -107,7 +107,8 @@ const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = ['agentSession.rewind', 195_000], // A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s // for an account switch to settle and 5 s of login-shell environment. - ['agentSession.modelCatalog', 90_000] + ['agentSession.modelCatalog', 90_000], + ['agentSession.sleepWorkspace', 120_000] ]) export async function callStructuredAgentSession( diff --git a/src/renderer/src/runtime/use-host-structured-agent-availability.ts b/src/renderer/src/runtime/use-host-structured-agent-availability.ts new file mode 100644 index 000000000000..14633563f026 --- /dev/null +++ b/src/renderer/src/runtime/use-host-structured-agent-availability.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react' +import { + acquireHostStructuredAgentAvailability, + readHostStructuredAgentAvailability, + subscribeHostStructuredAgentAvailability, + type HostStructuredAgentAvailability +} from './host-structured-agent-availability' +import { structuredAgentSessionTargetForHost } from './structured-agent-session-owner' + +type AvailabilityOwner = { + executionHostId: string + runtimeId: string | null | undefined + pairingRevision: number | undefined + /** Changes on reconnect, which reopens a lost stream without dropping the snapshot. */ + contact: string + /** Whether this caller holds the stream open; the last snapshot is read either way. */ + hold: boolean +} + +/** The host's agent health snapshot, with no workspace in it, so switching between workspaces on + * one host reads what it already has. Null for a host that does not publish it. */ +export function useHostStructuredAgentAvailability( + owner: AvailabilityOwner | null +): HostStructuredAgentAvailability | null { + const { executionHostId = '', runtimeId, pairingRevision, contact, hold = false } = owner ?? {} + const read = useCallback( + () => readHostStructuredAgentAvailability(executionHostId, runtimeId, pairingRevision), + [executionHostId, runtimeId, pairingRevision] + ) + const availability = useSyncExternalStore(subscribeHostStructuredAgentAvailability, read, read) + useEffect(() => { + const target = structuredAgentSessionTargetForHost(executionHostId) + if (!hold || !target || runtimeId === undefined) { + return + } + return acquireHostStructuredAgentAvailability({ + target, + executionHostId, + runtimeId, + pairingRevision + }) + }, [hold, executionHostId, runtimeId, pairingRevision, contact]) + return owner ? availability : null +} diff --git a/src/renderer/src/runtime/web-runtime-pane-layout-intent.test.ts b/src/renderer/src/runtime/web-runtime-pane-layout-params.test.ts similarity index 76% rename from src/renderer/src/runtime/web-runtime-pane-layout-intent.test.ts rename to src/renderer/src/runtime/web-runtime-pane-layout-params.test.ts index e4041086105a..54463f69c018 100644 --- a/src/renderer/src/runtime/web-runtime-pane-layout-intent.test.ts +++ b/src/renderer/src/runtime/web-runtime-pane-layout-params.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { UpdatePaneLayout } from '../../../shared/rpc-contract/session-tabs-schemas-params' import { updateWebRuntimePaneLayout } from './web-runtime-terminal-actions' import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' @@ -51,31 +50,19 @@ async function sentParams( return runtimeCall.mock.calls[0][0].params } -describe('updateWebRuntimePaneLayout intent marker', () => { +describe('updateWebRuntimePaneLayout params', () => { beforeEach(() => { store.state = ownedBy(`runtime:${ENVIRONMENT_ID}`) }) afterEach(() => vi.unstubAllGlobals()) - it('sends exactly the unmarked params when no intent is given', async () => { + it('sends exactly the layout params', async () => { const params = await sentParams(LAYOUT) expect(Object.keys(params).sort()).toEqual( ['chatLeafId', 'expandedLeafId', 'root', 'tabId', 'titlesByLeafId', 'worktree'].sort() ) }) - it('adds only the intent field for a gesture edit', async () => { - const unmarked = await sentParams(LAYOUT) - const marked = await sentParams({ ...LAYOUT, intent: 'gesture' }) - expect(marked).toEqual({ ...unmarked, intent: 'gesture' }) - }) - - it('round-trips the marker through the host params schema', async () => { - const marked = await sentParams({ ...LAYOUT, intent: 'gesture' }) - expect(UpdatePaneLayout.parse(marked).intent).toBe('gesture') - expect(UpdatePaneLayout.parse({ ...marked, intent: 'unknown' }).intent).toBeUndefined() - }) - it('refuses a local twin of the paired server and pushes nothing', async () => { store.state = ownedBy('local', `runtime:${ENVIRONMENT_ID}`) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 4e43146dd3a5..8887eebe4dd4 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -17,6 +17,10 @@ import { stubTerminalCreateEnvironment } from './web-runtime-session-test-harness' import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' +import { clearRuntimeCompatibilityCache } from './runtime-rpc-client' +import { createRuntimeAgentBackgroundTerminal } from '../lib/runtime-agent-background-create' +import { launchAgentInWebHostTab } from '../lib/launch-agent-web-host-tab' +import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -628,4 +632,146 @@ describe('createWebRuntimeSessionTerminal', () => { text: 'https://github.com/o/r/issues/12' }) }) + + it.each(['background', 'web-host'] as const)( + 'refuses %s OMP retry after the first host capability is invalidated', + async (lane) => { + let supportsThinking = true + let creates = 0 + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + ...(supportsThinking ? ['agent.launch.omp-thinking.v1'] : []) + ] + } + } + } + if (request.method === 'terminal.createAgentSession') { + creates += 1 + if (creates === 1) { + supportsThinking = false + clearRuntimeCompatibilityCache(ENVIRONMENT_ID) + throw new Error('connection closed before response') + } + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'incorrect-replacement-create', + tabId: 'host-tab-replayed', + paneKey: `host-tab-replayed:${FOCUS_LEAF_ID}` + } + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + const outcome = + lane === 'background' + ? await createRuntimeAgentBackgroundTerminal({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + tabId: 'tab-1', + leafId: FOCUS_LEAF_ID, + agent: 'omp', + sessionOptions: { model: 'opus', effort: 'off' }, + legacy: { + command: 'omp', + env: {}, + launchConfig: { agentArgs: '', agentEnv: {} }, + launchToken: 'owned-test-launch' + } + }).then( + () => 'created', + () => 'refused' + ) + : ( + await createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + launchAgent: 'omp', + launchPreferences: { model: 'opus', effort: 'off' } + }) + ).status === 'failed' + ? 'refused' + : 'created' + + expect(outcome).toBe('refused') + expect(creates).toBe(1) + } + ) + + it('preserves prepared OMP thinking argv for a promptless web legacy launch', async () => { + const state = mocks.getState() + mocks.getState.mockReturnValue({ ...state, setActiveTabType: vi.fn() }) + const creates: { + command?: string + env?: Record + launchConfig?: { agentEnv?: Record | null; agentCommand?: string } + launchAgent?: string + }[] = [] + const runtimeCall = vi.fn( + async (request: { method: string; params?: (typeof creates)[number] }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent.launch.omp-thinking.v1'] + } + } + } + if (request.method === 'session.tabs.createTerminal') { + creates.push(request.params ?? {}) + return { id: 'create', ok: true, result: { tab: { id: 'host-tab-1' } } } + } + return { id: 'list', ok: true, result: makeSnapshot() } + } + ) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + const startupPlan = buildAgentStartupPlan({ + agent: 'omp', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + agentEnv: { OMP_TEST_CUSTOM_CONFIG_ROOT: '/host/custom-omp-config' }, + sessionOptions: { model: 'anthropic/claude-opus-5-5', effort: 'off' } + }) + if (!startupPlan) { + throw new Error('Missing OMP launch plan') + } + + await launchAgentInWebHostTab({ + agent: 'omp', + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + startupPlan, + prompt: '', + promptDelivery: 'auto-submit', + pastePromptAfterReady: null, + submitPastedPrompt: false + }) + + expect(creates).toHaveLength(1) + expect(creates[0].command).toMatch(/['"]?--thinking['"]?\s+['"]?off(?:['"]|\s|$)/) + expect(creates[0].command).toMatch( + /['"]?--model['"]?\s+['"]?anthropic\/claude-opus-5-5(?:['"]|\s|$)/ + ) + expect(creates[0].env?.OMP_TEST_CUSTOM_CONFIG_ROOT).toBe('/host/custom-omp-config') + expect(creates[0].launchAgent).toBe('omp') + expect(creates[0].launchConfig?.agentEnv?.OMP_TEST_CUSTOM_CONFIG_ROOT).toBe( + '/host/custom-omp-config' + ) + }) }) diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 97fa9d4aa4dd..aeb015419d91 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -47,6 +47,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { export type CreatedWebRuntimeSessionTerminal = { outcome: WebRuntimeTerminalCreateOutcome hostTabId?: string + /** A live session existed and the create was not confirmed, as opposed to no session to ask. */ + hostFailed?: true } export type CreatedAgentTerminalIdentity = Pick & { diff --git a/src/renderer/src/runtime/web-runtime-terminal-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-actions.ts index 7a22c132cd52..7d1aa1b7cbc2 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-actions.ts @@ -3,11 +3,10 @@ import type { TerminalPaneSplitSource } from '../../../shared/feature-education- import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeTerminalClose, RuntimeTerminalSplit } from '../../../shared/runtime-types' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { PaneLayoutEditIntent } from '../../../shared/rpc-contract/session-tabs-schemas-params' import { runtimeEnvironmentIdUnlessTwin } from '../lib/resolve-owner' import { useAppStore } from '../store' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '../../../shared/terminal-surface-id' import { @@ -201,7 +200,6 @@ export async function updateWebRuntimePaneLayout(args: { chatLeafId?: string | null titlesByLeafId?: Record renamedLeafIds?: string[] - intent?: PaneLayoutEditIntent }): Promise { // Why: a twin's edit must not reach either host on a guess; it stays local until one is chosen. const environmentId = runtimeEnvironmentIdUnlessTwin( @@ -226,8 +224,7 @@ export async function updateWebRuntimePaneLayout(args: { expandedLeafId: args.expandedLeafId, ...(args.chatLeafId !== undefined ? { chatLeafId: args.chatLeafId } : {}), ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}), - ...(args.renamedLeafIds ? { renamedLeafIds: args.renamedLeafIds } : {}), - ...(args.intent ? { intent: args.intent } : {}) + ...(args.renamedLeafIds ? { renamedLeafIds: args.renamedLeafIds } : {}) }, timeoutMs: 15_000 }) diff --git a/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts index 35e42346a59e..a44622a4c2b1 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-buffer-actions.ts @@ -1,5 +1,5 @@ import { unwrapRuntimeRpcResult } from './runtime-rpc-result' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { isWebRuntimeSessionActive } from './web-runtime-session-environment' // Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks. diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.test.ts b/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.test.ts new file mode 100644 index 000000000000..23ecb7e7390a --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation' +import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { + WORKTREE_ID, + resetTerminalCreateEnvironment, + stubTerminalCreateEnvironment +} from './web-runtime-session-test-harness' +import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + setState: vi.fn(), + subscribe: vi.fn(), + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn(), + closeEmptyGroup: vi.fn(), + moveUnifiedTabToGroup: vi.fn(), + setRemoteBrowserPageHandle: vi.fn(), + focusBrowserTabInWorktree: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + getWebSessionTabsTrackingGeneration: vi.fn(() => 0), + acceptReplayedWebSessionTabsSnapshot: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn(), + trackTerminalPaneSplit: vi.fn(), + deliverLaunchPromptToAgentTab: vi.fn(), + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + hasMaterializedWebRuntimeBrowserPage: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('../store', () => ({ + useAppStore: { + getState: mocks.getState, + setState: mocks.setState, + subscribe: mocks.subscribe + } +})) + +vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ + acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration +})) +vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({ + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot +})) +vi.mock('./web-session-tabs-sync/store-patch', () => ({ + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return () => {} + } +})) +vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab +})) + +vi.mock('@/lib/feature-education-telemetry', () => ({ + trackTerminalPaneSplit: mocks.trackTerminalPaneSplit +})) + +vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ + deliverLaunchPromptToAgentTab: mocks.deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab +})) + +vi.mock('./web-runtime-browser-materialization', () => ({ + hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) + +afterEach(() => resetWebSessionCloseIntentForTests()) + +const HOST_REFUSAL = 'Profile state write failed without a confirmed rollback' + +describe('a terminal the host fails to create', () => { + beforeEach(() => { + stubTerminalCreateEnvironment(mocks) + }) + + afterEach(() => { + resetTerminalCreateEnvironment() + }) + + it('passes on the hostโ€™s reason, since no tab or pane will ever show it', async () => { + stubRuntimeEnvironmentBridge({ + call: vi.fn().mockResolvedValue({ + id: 'create', + ok: false, + error: { code: 'runtime_error', message: HOST_REFUSAL } + }) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect( + createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true }) + ).resolves.toMatchObject({ status: 'failed' }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + 'Orca couldnโ€™t confirm that the new terminal opened on the host.', + expect.objectContaining({ description: expect.stringContaining(HOST_REFUSAL) }) + ) + // One toast per workspace however often automatic seeding retries. + expect(mocks.toastError.mock.calls[0][1]).toMatchObject({ + id: `web-runtime-terminal-create:active:${WORKTREE_ID}` + }) + }) + + it('says unconfirmed, never failed, when the reply was lost', async () => { + stubRuntimeEnvironmentBridge({ + call: vi.fn().mockResolvedValue({ + id: 'create', + ok: false, + error: { code: 'runtime_timeout', message: 'Timed out waiting for the remote Orca runtime' } + }) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + 'Orca couldnโ€™t confirm that the new terminal opened on the host.', + expect.objectContaining({ description: expect.stringContaining('Timed out') }) + ) + }) + + it('says the same for a browser client request timeout', async () => { + stubRuntimeEnvironmentBridge({ + call: vi.fn().mockRejectedValue(new Error('Request timed out: session.tabs.createTerminal')) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + 'Orca couldnโ€™t confirm that the new terminal opened on the host.', + expect.objectContaining({ description: expect.stringContaining('Request timed out') }) + ) + }) + + it('stays quiet for a caller that reports the failure itself', async () => { + stubRuntimeEnvironmentBridge({ + call: vi.fn().mockResolvedValue({ + id: 'create', + ok: false, + error: { code: 'runtime_error', message: HOST_REFUSAL } + }) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await createWebRuntimeSessionTerminal( + { worktreeId: WORKTREE_ID, activate: true }, + { notifyFailure: false } + ) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('stays quiet when no host session exists, so callers can fall back to a local tab', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getState.mockReturnValue({ ...mocks.getState(), settings: {} }) + await expect( + createWebRuntimeSessionTerminal({ worktreeId: WORKTREE_ID, activate: true }) + ).resolves.toMatchObject({ status: 'failed' }) + expect(mocks.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.ts b/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.ts new file mode 100644 index 000000000000..2fefbb0aff6c --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-create-failure-notice.ts @@ -0,0 +1,24 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +/** + * The user asked for a terminal and the host never mirrored one, so no pane exists to show why. + * "Couldn't confirm" stays true when the reply was lost and the terminal appears later; the reason + * carries the host's refusal or the transport failure as reported. No retry: it could duplicate. + */ +export function notifyWebRuntimeTerminalCreateFailure( + reason: string, + scope: { environmentId: string | null; worktreeId: string } +): void { + toast.error( + translate( + 'auto.runtime.webRuntimeSession.terminalCreateUnconfirmed', + 'Orca couldnโ€™t confirm that the new terminal opened on the host.' + ), + { + // Why an id: automatic seeding retries on every reconnect and must not stack toasts. + id: `web-runtime-terminal-create:${scope.environmentId ?? 'active'}:${scope.worktreeId}`, + ...(reason ? { description: reason } : {}) + } + ) +} diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index fefa66f4f661..21b8a27ed6d9 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -10,7 +10,10 @@ import { createAgentSessionCreateOperation, withAgentSessionCreateOperationId } from './agent-session-create-operation' -import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' +import { + assertRemoteAgentThinkingSupported, + runRemoteAgentSessionLaunch +} from './remote-agent-session-launch' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { resolveWebRuntimeSessionEnvironmentId } from './web-runtime-session-workspace-routing' @@ -127,14 +130,22 @@ export async function createWebRuntimeSessionTerminalResult( ) : undefined : async () => - await createAgentSessionCreateOperation().run(async (clientOperationId) => - readCreatedAgentTerminalIdentity( + await createAgentSessionCreateOperation().run(async (clientOperationId) => { + const keyboard = await keyboardOptions(environmentId) + if (agent === 'omp') { + await assertRemoteAgentThinkingSupported({ + environmentId, + agent, + launchPreferences: args.launchPreferences + }) + } + return readCreatedAgentTerminalIdentity( unwrapRuntimeRpcResult( await callEnvironment({ method: 'terminal.createAgentSession', params: withAgentSessionCreateOperationId( { - ...(await keyboardOptions(environmentId)), + ...keyboard, worktree: toRuntimeWorktreeSelector(args.worktreeId), agent, ...(args.prompt ? { prompt: args.prompt } : {}), @@ -155,13 +166,15 @@ export async function createWebRuntimeSessionTerminalResult( }) ) ) - ) + }) const resumeHostAuthorityCapability = args.agentSessionKind === 'resume' ? agentResumeHostAuthorityCapability(agent) : undefined const created = await runRemoteAgentSessionLaunch<{ terminal: CreatedAgentTerminalIdentity }>({ environmentId, + agent, + launchPreferences: args.launchPreferences, ...(hostAuthority ? { hostAuthority } : {}), ...(resumeHostAuthorityCapability ? { hostAuthorityCapability: resumeHostAuthorityCapability } @@ -296,7 +309,8 @@ export async function createWebRuntimeSessionTerminalResult( // to retry with a new operation ID and can duplicate a fresh agent. return { outcome: hostCreated ? { status: 'created' } : { status: 'failed', message }, - ...(createdTabId ? { hostTabId: createdTabId } : {}) + ...(createdTabId ? { hostTabId: createdTabId } : {}), + ...(hostCreated ? {} : { hostFailed: true as const }) } } } diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index 42567f76ba52..bde5df66a78c 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -4,16 +4,33 @@ import { seedNativeChatLaunchDraftForAgentTab } from '../lib/agent-launch-prompt-delivery' import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation' +import { notifyWebRuntimeTerminalCreateFailure } from './web-runtime-terminal-create-failure-notice' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { CreateWebRuntimeSessionTerminalArgs, WebRuntimeTerminalCreateOutcome } from './web-runtime-session-types' +/** + * A host-side failure is toasted here because the host never mirrors a tab for it, so no pane + * exists to show the error. `notifyFailure: false` is for callers that report it themselves. + */ export async function createWebRuntimeSessionTerminal( - args: CreateWebRuntimeSessionTerminalArgs + args: CreateWebRuntimeSessionTerminalArgs, + options: { notifyFailure?: boolean } = {} ): Promise { - return (await createWebRuntimeSessionTerminalResult(args)).outcome + const created = await createWebRuntimeSessionTerminalResult(args) + if ( + created.hostFailed && + created.outcome.status === 'failed' && + options.notifyFailure !== false + ) { + notifyWebRuntimeTerminalCreateFailure(created.outcome.message, { + environmentId: args.environmentId ?? null, + worktreeId: args.worktreeId + }) + } + return created.outcome } export async function createWebRuntimeAgentSessionTerminal( diff --git a/src/renderer/src/runtime/web-runtime-terminal-rename.ts b/src/renderer/src/runtime/web-runtime-terminal-rename.ts index 7413161862a2..548cbc130395 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-rename.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-rename.ts @@ -1,7 +1,7 @@ import type { TerminalTab, TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { isWebRuntimeSessionActive } from './web-runtime-session-environment' import { setWebRuntimeTabProps } from './web-runtime-terminal-actions' diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index ba3a22d34415..19b1ae5ea012 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -40,7 +40,7 @@ import { useAppStore } from '@/store' import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import type { AppState } from '@/store/types' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { subscribeAcceptedWebSessionTerminalHandle } from './web-session-terminal-handle-events' import { _getWebSessionTabsReceiptTrackingCountsForTest, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts index c14aa2832187..796b678ff98f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -20,7 +20,7 @@ import { isWebAgentSessionHandoffPostCreateSnapshotConfirmed, clearWebAgentSessionHandoff } from '../web-agent-session-handoff' -import { toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' import { isReadyTerminalTab, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts index f93f1bb4fd8a..616cb91c8a85 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -3,7 +3,8 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter import { normalizeTerminalLayoutPtyOwnership } from '../../../../shared/terminal-layout-pty-ownership' import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' -import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { getRemoteRuntimePtyEnvironmentId } from '../runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { toWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' import type { MirroredTerminalTab, TerminalSurface, ReadyTerminalSurface } from './state' import { chooseRemoteTerminalLayout, isTerminalSurfaceTab } from './terminal-surfaces' diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts index 5d117ea7ed01..76880d64eb88 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions' diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts index 57c59b466993..910553d2e09f 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { finalizeHostTerminalSnapshot } from './__fixtures__/web-session-terminal-host-finalization' import { makeState as makeTabsSyncState, diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts index 67d5cd65b4f9..de75fe43f104 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts @@ -3,7 +3,7 @@ import type { RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { ENVIRONMENT_ID, deferred, diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts index 9e60ac6ed26f..83623be6743c 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-surface.ts @@ -6,7 +6,7 @@ import type { RuntimeTerminalOrphanAdoptionClaim } from '../../../shared/runtime-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '../../../shared/terminal-surface-id' import { hasTerminalHandleRetirementProof, diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts index c1a31e7c243f..7b41ea294076 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-topology-fence.test.ts @@ -12,7 +12,7 @@ import { clearWebSessionTerminalOrphanRecoveryForTests, recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' describe('web session terminal orphan recovery topology fence', () => { beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests()) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts index 532a15f47889..c200a0f016d2 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { clearWebSessionTerminalOrphanRecoveryForTests, recoverWebSessionTerminalOrphansBeforeApply diff --git a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts index f072f1b09ab9..89631623bfd7 100644 --- a/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { toRemoteRuntimePtyId } from './runtime-terminal-stream' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { clearWebSessionTerminalOrphanRecoveryForTests, recoverWebSessionTerminalOrphansBeforeApply diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts index 4e2803c96db8..a2975e6fd5bc 100644 --- a/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts +++ b/src/renderer/src/ssh/ssh-host-server-status-copy.test.ts @@ -149,6 +149,15 @@ describe('SSH host server status line', () => { expect(line?.text).not.toContain('relay') }) + it('tells a noexec host what to change, not to install an older Orca', () => { + const line = sshHostServerStatusLine(plain, { + managedServer: { kind: 'relay', reason: 'orcad_unavailable', detail: 'runtime_noexec' } + }) + expect(line).toMatchObject({ tone: 'destructive', text: expect.stringContaining('noexec') }) + expect(line?.text).toContain('connect again') + expect(line?.text).not.toContain('older version') + }) + it('says plainly when neither port forwarding nor the SSH session reaches the server', () => { const live = sshHostServerStatusLine(plain, { managedServer: { diff --git a/src/renderer/src/ssh/ssh-host-server-status-copy.ts b/src/renderer/src/ssh/ssh-host-server-status-copy.ts index d31d9f1feafe..5af8508abb64 100644 --- a/src/renderer/src/ssh/ssh-host-server-status-copy.ts +++ b/src/renderer/src/ssh/ssh-host-server-status-copy.ts @@ -102,6 +102,15 @@ function unavailableLine(reason: string | undefined): SshHostServerStatusLine { if (reason === TUNNEL_UNAVAILABLE) { return tunnelUnavailableLine() } + if (reason === 'runtime_noexec') { + return { + tone: 'destructive', + text: translate( + 'auto.components.settings.sshHostServer.runtimeNoexec', + 'This host doesnโ€™t allow running programs from ~/.orca-remote, so Orca canโ€™t start its server there. This usually means the home folder is mounted with noexec. Ask the hostโ€™s administrator to allow running programs there, then connect again.' + ) + } + } const cause = reason ? unavailableCause(reason) : null if (cause) { return { diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index 81d6082d0181..11d1acf0a7d1 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -293,7 +293,7 @@ export function createFolderWorkspaceMutationActions( if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { // Folder workspaces use the same browser registry key as worktrees; // tear down Chromium guests before purging the remaining renderer state. - await get().shutdownWorktreeBrowsers(workspaceKey) + await Promise.resolve(get().shutdownWorktreeBrowsers(workspaceKey)) get().purgeWorktreeTerminalState([workspaceKey]) deleteWorkspaceChatDrafts(chatDraftKeys) } diff --git a/src/renderer/src/store/plugin-content-list-store.ts b/src/renderer/src/store/plugin-content-list-store.ts new file mode 100644 index 000000000000..1a2efd43cdd0 --- /dev/null +++ b/src/renderer/src/store/plugin-content-list-store.ts @@ -0,0 +1,97 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import type { PluginsApi } from '../../../preload/api/plugin-host-api' + +type PluginContentListState = { + entries: Entry[] + loaded: boolean + fetchEntries: () => Promise +} + +/** + * Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin + * discovery โ€” a wedged one must not turn `ensureLoaded` into a no-op for the session, which would + * pin every consumer on built-in content. Past this bound the pending request is wedged rather than + * slow, so a later consumer starts its own (mirrors the reasoning in + * `src/main/git/coalesced-probe.ts`). + */ +const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000 + +/** One renderer list of a plugin content family, refetched whenever plugin content changes. */ +export function createPluginContentListStore(options: { + /** Plural noun for logs, e.g. "language packs". */ + kind: string + list: (api: PluginsApi) => (() => Promise) | undefined + isEntry: (value: unknown) => value is Entry +}) { + let requestGeneration = 0 + let latestRequestStartedAt: number | null = null + let changeSubscriptionStarted = false + + const useStore = create>()((set) => ({ + entries: [], + loaded: false, + fetchEntries: async () => { + const generation = ++requestGeneration + latestRequestStartedAt = Date.now() + const api = window.api?.plugins + const list = api ? options.list(api) : undefined + if (!list) { + if (generation === requestGeneration) { + latestRequestStartedAt = null + set({ entries: [], loaded: true }) + } + return + } + try { + const response = await list() + const entries = Array.isArray(response) ? response.filter(options.isEntry) : [] + // Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log. + if (!Array.isArray(response)) { + console.warn(`[plugins] Ignoring non-array ${options.kind} list (${typeof response})`) + } else if (entries.length !== response.length) { + console.warn( + `[plugins] Ignoring ${response.length - entries.length} of ${response.length} malformed ${options.kind}` + ) + } + if (generation === requestGeneration) { + set({ entries, loaded: true }) + } + } catch { + if (generation === requestGeneration) { + set({ entries: [], loaded: true }) + } + } finally { + if (generation === requestGeneration) { + latestRequestStartedAt = null + } + } + } + })) + + function ensureLoaded(): void { + const state = useStore.getState() + const joinable = + latestRequestStartedAt !== null && + Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS + if (!state.loaded && !joinable) { + void state.fetchEntries() + } + if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) { + changeSubscriptionStarted = true + window.api.plugins.onChanged((event) => { + if (event?.contentPacksChanged ?? true) { + void useStore.getState().fetchEntries() + } + }) + } + } + + function useEntries(): Entry[] { + const entries = useStore((state) => state.entries) + useEffect(() => ensureLoaded(), []) + return entries + } + + return { useStore, ensureLoaded, useEntries } +} diff --git a/src/renderer/src/store/plugin-language-packs-startup.test.ts b/src/renderer/src/store/plugin-language-packs-startup.test.ts index 6cb5a9c57114..2f74f677f8be 100644 --- a/src/renderer/src/store/plugin-language-packs-startup.test.ts +++ b/src/renderer/src/store/plugin-language-packs-startup.test.ts @@ -77,8 +77,8 @@ describe('plugin language-pack startup ownership', () => { async (settlement) => { const bridge = installBridge() const store = languagePacks.usePluginLanguagePackStore - const older = store.getState().fetchPacks() - const newer = store.getState().fetchPacks() + const older = store.getState().fetchEntries() + const newer = store.getState().fetchEntries() if (settlement === 'resolve') { bridge.requests[0].resolve([pack('old')]) } else { @@ -93,7 +93,7 @@ describe('plugin language-pack startup ownership', () => { } await newer expect(dispatches).toBe(2) - expect(store.getState().packs).toEqual([pack('current')]) + expect(store.getState().entries).toEqual([pack('current')]) } ) @@ -110,7 +110,7 @@ describe('plugin language-pack startup ownership', () => { await bridge.requests[0].promise languagePacks.ensurePluginLanguagePacksLoaded() expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(2) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('changed')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('changed')]) }) it('keeps the shared startup request alive after the first consumer unmounts', async () => { @@ -146,7 +146,9 @@ describe('plugin language-pack startup ownership', () => { await vi.waitFor(() => expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true) ) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('recovered')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([ + pack('recovered') + ]) } finally { vi.useRealTimers() } @@ -155,17 +157,17 @@ describe('plugin language-pack startup ownership', () => { it('retains fail-closed loading and permits an explicit retry after rejection', async () => { const bridge = installBridge() const store = languagePacks.usePluginLanguagePackStore - const failed = store.getState().fetchPacks() + const failed = store.getState().fetchEntries() bridge.requests[0].reject(new Error('bridge unavailable')) await failed expect(store.getState().loaded).toBe(true) - expect(store.getState().packs).toEqual([]) + expect(store.getState().entries).toEqual([]) languagePacks.ensurePluginLanguagePacksLoaded() expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1) - const retry = store.getState().fetchPacks() + const retry = store.getState().fetchEntries() bridge.requests[1].resolve([pack('retry')]) await retry - expect(store.getState().packs).toEqual([pack('retry')]) + expect(store.getState().entries).toEqual([pack('retry')]) }) it('finishes without an older bridge API and admits a later explicit refresh', async () => { @@ -173,10 +175,10 @@ describe('plugin language-pack startup ownership', () => { languagePacks.ensurePluginLanguagePacksLoaded() expect(languagePacks.usePluginLanguagePackStore.getState().loaded).toBe(true) const bridge = installBridge() - const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchPacks() + const refresh = languagePacks.usePluginLanguagePackStore.getState().fetchEntries() bridge.requests[0].resolve([pack('available')]) await refresh expect(bridge.listLanguagePacks).toHaveBeenCalledTimes(1) - expect(languagePacks.usePluginLanguagePackStore.getState().packs).toEqual([pack('available')]) + expect(languagePacks.usePluginLanguagePackStore.getState().entries).toEqual([pack('available')]) }) }) diff --git a/src/renderer/src/store/plugin-language-packs.test.ts b/src/renderer/src/store/plugin-language-packs.test.ts index 53a608d89f4d..3cfb6c49498d 100644 --- a/src/renderer/src/store/plugin-language-packs.test.ts +++ b/src/renderer/src/store/plugin-language-packs.test.ts @@ -8,7 +8,7 @@ import { usePluginLanguagePackStore } from './plugin-language-packs' afterEach(() => { vi.restoreAllMocks() setRendererPluginLanguagePacks([]) - usePluginLanguagePackStore.setState({ packs: [], loaded: false }) + usePluginLanguagePackStore.setState({ entries: [], loaded: false }) vi.unstubAllGlobals() }) @@ -22,12 +22,12 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() const state = usePluginLanguagePackStore.getState() expect(state.loaded).toBe(true) - expect(state.packs).toEqual([]) - expect(() => state.packs.find(() => true)).not.toThrow() + expect(state.entries).toEqual([]) + expect(() => state.entries.find(() => true)).not.toThrow() }) it('filters malformed language-pack array members without changing valid order', async () => { @@ -55,9 +55,9 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() - expect(usePluginLanguagePackStore.getState().packs).toEqual([first, second]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([first, second]) }) it.each([ @@ -81,9 +81,9 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() + await usePluginLanguagePackStore.getState().fetchEntries() - expect(usePluginLanguagePackStore.getState().packs).toEqual([]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([]) }) it('rejects a structured-cloned cyclic catalog before i18next resource installation', async () => { @@ -103,8 +103,8 @@ describe('plugin language pack loading', () => { api: { plugins: { listLanguagePacks: vi.fn().mockResolvedValue(response) } } }) - await usePluginLanguagePackStore.getState().fetchPacks() - const packs = usePluginLanguagePackStore.getState().packs + await usePluginLanguagePackStore.getState().fetchEntries() + const packs = usePluginLanguagePackStore.getState().entries expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow() expect(packs).toEqual([]) @@ -127,8 +127,8 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() - setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().packs) + await usePluginLanguagePackStore.getState().fetchEntries() + setRendererPluginLanguagePacks(usePluginLanguagePackStore.getState().entries) const removeResourceBundle = vi .spyOn(i18n, 'removeResourceBundle') .mockImplementation((language) => { @@ -156,8 +156,8 @@ describe('plugin language pack loading', () => { } }) - await usePluginLanguagePackStore.getState().fetchPacks() - const packs = usePluginLanguagePackStore.getState().packs + await usePluginLanguagePackStore.getState().fetchEntries() + const packs = usePluginLanguagePackStore.getState().entries expect(() => setRendererPluginLanguagePacks(packs)).not.toThrow() expect(packs).toEqual([]) @@ -193,11 +193,11 @@ describe('plugin language pack loading', () => { } }) - const olderFetch = usePluginLanguagePackStore.getState().fetchPacks() - await usePluginLanguagePackStore.getState().fetchPacks() + const olderFetch = usePluginLanguagePackStore.getState().fetchEntries() + await usePluginLanguagePackStore.getState().fetchEntries() resolveOlder([older]) await olderFetch - expect(usePluginLanguagePackStore.getState().packs).toEqual([latest]) + expect(usePluginLanguagePackStore.getState().entries).toEqual([latest]) }) }) diff --git a/src/renderer/src/store/plugin-language-packs.ts b/src/renderer/src/store/plugin-language-packs.ts index 009ab459612c..20b7c672840b 100644 --- a/src/renderer/src/store/plugin-language-packs.ts +++ b/src/renderer/src/store/plugin-language-packs.ts @@ -1,89 +1,15 @@ -import { useEffect } from 'react' -import { create } from 'zustand' import { isPluginLanguagePackRegistration, type PluginLanguagePackRegistration } from '../../../shared/plugins/plugin-language-pack-artifact' +import { createPluginContentListStore } from './plugin-content-list-store' -type PluginLanguagePackState = { - packs: PluginLanguagePackRegistration[] - loaded: boolean - fetchPacks: () => Promise -} +const languagePacks = createPluginContentListStore({ + kind: 'language packs', + list: (api) => api.listLanguagePacks, + isEntry: isPluginLanguagePackRegistration +}) -/** - * Joining the startup request saves a duplicate IPC round trip, but its handler awaits plugin - * discovery โ€” a wedged one must not turn `ensurePluginLanguagePacksLoaded` into a no-op for the - * session, which would pin every consumer on built-in translations. Past this bound the pending - * request is wedged rather than slow, so a later consumer starts its own (mirrors the reasoning in - * `src/main/git/coalesced-probe.ts`). - */ -const STARTUP_REQUEST_JOIN_WINDOW_MS = 10_000 - -let requestGeneration = 0 -let latestRequestStartedAt: number | null = null -let changeSubscriptionStarted = false - -export const usePluginLanguagePackStore = create()((set) => ({ - packs: [], - loaded: false, - fetchPacks: async () => { - const generation = ++requestGeneration - latestRequestStartedAt = Date.now() - const api = window.api?.plugins - if (!api?.listLanguagePacks) { - if (generation === requestGeneration) { - latestRequestStartedAt = null - set({ packs: [], loaded: true }) - } - return - } - try { - const response = await api.listLanguagePacks() - const packs = Array.isArray(response) ? response.filter(isPluginLanguagePackRegistration) : [] - // Why: a non-array response and a rejected member are different upstream bugs; keep them distinguishable in the log. - if (!Array.isArray(response)) { - console.warn(`[plugins] Ignoring non-array language-pack list (${typeof response})`) - } else if (packs.length !== response.length) { - console.warn( - `[plugins] Ignoring ${response.length - packs.length} of ${response.length} malformed language packs` - ) - } - if (generation === requestGeneration) { - set({ packs, loaded: true }) - } - } catch { - if (generation === requestGeneration) { - set({ packs: [], loaded: true }) - } - } finally { - if (generation === requestGeneration) { - latestRequestStartedAt = null - } - } - } -})) - -export function ensurePluginLanguagePacksLoaded(): void { - const state = usePluginLanguagePackStore.getState() - const joinable = - latestRequestStartedAt !== null && - Date.now() - latestRequestStartedAt < STARTUP_REQUEST_JOIN_WINDOW_MS - if (!state.loaded && !joinable) { - void state.fetchPacks() - } - if (!changeSubscriptionStarted && window.api?.plugins?.onChanged) { - changeSubscriptionStarted = true - window.api.plugins.onChanged((event) => { - if (event?.contentPacksChanged ?? true) { - void usePluginLanguagePackStore.getState().fetchPacks() - } - }) - } -} - -export function usePluginLanguagePacks(): PluginLanguagePackRegistration[] { - const packs = usePluginLanguagePackStore((state) => state.packs) - useEffect(() => ensurePluginLanguagePacksLoaded(), []) - return packs -} +export const usePluginLanguagePackStore = languagePacks.useStore +export const ensurePluginLanguagePacksLoaded = languagePacks.ensureLoaded +export const usePluginLanguagePacks = languagePacks.useEntries diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 2cac33a75030..f3194b3542b5 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -19,6 +19,7 @@ import { } from '../../runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { adoptEndpointProjectCatalog } from '../../../../shared/endpoint-catalog-adoption' +import type { ProjectHostSetupTarget } from '../../../../shared/project-host-setup-target' export function getProjectSetupRuntimeTarget( hostId: ProjectHostSetupExistingFolderArgs['hostId'] @@ -94,3 +95,49 @@ export async function assertProjectHostSetupMutationRuntimeCapabilities( 15_000 ) } + +// Why: twins share `id`. A paired server's setups all carry its one `runtime:` hostId, so they +// stay matched by id there, as before (the server's own by-id answer decides which one it was). +export function isSameProjectHostSetup(a: ProjectHostSetup, b: ProjectHostSetup): boolean { + return a.id === b.id && a.hostId === b.hostId +} + +export function upsertProjectHostSetup( + setups: readonly ProjectHostSetup[], + setup: ProjectHostSetup +): ProjectHostSetup[] { + return setups.some((entry) => isSameProjectHostSetup(entry, setup)) + ? setups.map((entry) => (isSameProjectHostSetup(entry, setup) ? setup : entry)) + : [...setups, setup] +} + +/** + * Where a setup edit or removal goes. This computer's catalog stamps every row with its real + * host, so a named host or a same-id twin goes on the wire; a paired server keeps the by-id + * request, because its rows' client-side host ids cannot name its twins. + */ +export async function prepareProjectHostSetupMutation( + setups: readonly ProjectHostSetup[], + args: T +): Promise<{ target: RuntimeClientTarget; hostArgs: T }> { + const setup = setups.find( + (entry) => + entry.id === args.setupId && (args.hostId === undefined || entry.hostId === args.hostId) + ) + if (!setup && args.hostId !== undefined) { + // Why: an id-only fallback could land on a same-id setup of another host. + throw new Error(`Project host setup not found on ${args.hostId}: ${args.setupId}`) + } + const target = setup ? getProjectSetupRuntimeTarget(setup.hostId) : { kind: 'local' as const } + await assertProjectHostSetupMutationRuntimeCapabilities(target) + if (!setup || target.kind === 'environment') { + return { target, hostArgs: { ...args, hostId: undefined } } + } + const twinned = setups.some( + (entry) => entry !== setup && entry.id === setup.id && entry.hostId !== setup.hostId + ) + return { + target, + hostArgs: twinned || args.hostId !== undefined ? { ...args, hostId: setup.hostId } : args + } +} diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 32e6470db355..6561d808bbfa 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -19,7 +19,10 @@ import { adoptFromEndpoint } from '../adopt-from-endpoint' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' import { assertProjectHostSetupMutationRuntimeCapabilities, - getProjectSetupRuntimeTarget + getProjectSetupRuntimeTarget, + isSameProjectHostSetup, + prepareProjectHostSetupMutation, + upsertProjectHostSetup } from './project-host-routing' export function createProjectHostSetupActions( @@ -123,9 +126,7 @@ export function createProjectHostSetupActions( projects: s.projects.some((entry) => entry.id === project.id) ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) : [...s.projects, project], - projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] + projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) })) return { project, setup } } catch (err) { @@ -141,19 +142,18 @@ export function createProjectHostSetupActions( updateProjectHostSetup: async (args) => { try { - const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) - const target = currentSetup - ? getProjectSetupRuntimeTarget(currentSetup.hostId) - : { kind: 'local' as const } - await assertProjectHostSetupMutationRuntimeCapabilities(target) + const { target, hostArgs } = await prepareProjectHostSetupMutation( + get().projectHostSetups, + args + ) const result = target.kind === 'local' - ? await window.api.projects.updateHostSetup(args) + ? await window.api.projects.updateHostSetup(hostArgs) : ( await callRuntimeRpc<{ result: ProjectHostSetupUpdateResult }>( target, 'projectHostSetup.update', - args, + hostArgs, { timeoutMs: 15_000 } ) ).result @@ -177,9 +177,7 @@ export function createProjectHostSetupActions( projects: s.projects.some((entry) => entry.id === project.id) ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) : [...s.projects, project], - projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] + projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) })) return { ...result, project, repo, setup } } catch (err) { @@ -195,19 +193,18 @@ export function createProjectHostSetupActions( deleteProjectHostSetup: async (args) => { try { - const currentSetup = get().projectHostSetups.find((setup) => setup.id === args.setupId) - const target = currentSetup - ? getProjectSetupRuntimeTarget(currentSetup.hostId) - : { kind: 'local' as const } - await assertProjectHostSetupMutationRuntimeCapabilities(target) + const { target, hostArgs } = await prepareProjectHostSetupMutation( + get().projectHostSetups, + args + ) const result = target.kind === 'local' - ? await window.api.projects.deleteHostSetup(args) + ? await window.api.projects.deleteHostSetup(hostArgs) : ( await callRuntimeRpc<{ result: ProjectHostSetupDeleteResult }>( target, 'projectHostSetup.delete', - args, + hostArgs, { timeoutMs: 15_000 } ) ).result @@ -215,9 +212,13 @@ export function createProjectHostSetupActions( ? adoptFromEndpoint(target, { kind: 'repo', row: result.repo }) : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null + const removedSetup = adoptFromEndpoint(target, { + kind: 'projectHostSetup', + row: result.setup + }) set((s) => { const projectHostSetups = s.projectHostSetups.filter( - (setup) => setup.id !== result.setup.id + (setup) => !isSameProjectHostSetup(setup, removedSetup) ) const repos = repo && repoHostId diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index b28c266e4ac2..a05f0d17c25f 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -200,6 +200,7 @@ export function createRepoRemovalActions( .flatMap((worktree) => (worktree.projectId ? [worktree.projectId] : [])) ] : [] + // Backstop for the host's sweep, which a spent budget or reloading graph can cut short. for (const wId of exclusiveWorktreeIds) { const tabs = get().tabsByWorktree[wId] ?? [] for (const tab of tabs) { diff --git a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index f25478b6025b..e6074b694821 100644 --- a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -66,6 +66,22 @@ export function getLeafIdFromPaneKey(paneKey: string): string | null { return leafId.length > 0 ? leafId : null } +/** Open non-terminal tabs (chats) whose rows their own projection writes and removes, so terminal + * cleanup must leave them alone. Terminal owners must still exist in the terminal inventory + * instead; unified metadata can lag a close. */ +export function openNonTerminalTabIds( + state: Pick, + worktreeId: string +): Set { + const tabIds = new Set() + for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType !== 'terminal') { + tabIds.add(tab.id) + } + } + return tabIds +} + export function findCompletedOrphanPaneKeysForTabClose( state: AgentStatusTabPrefixDropState, worktreeId: string | undefined, @@ -74,12 +90,9 @@ export function findCompletedOrphanPaneKeysForTabClose( if (!worktreeId) { return [] } - const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) - // Terminal owners must still exist in the terminal inventory; unified metadata can lag a close. - for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { - if (tab.contentType !== 'terminal') { - openTabIds.add(tab.id) - } + const openTabIds = openNonTerminalTabIds(state, worktreeId) + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + openTabIds.add(tab.id) } const paneKeys: string[] = [] for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index 71a373ecae9b..e7aa681a767f 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -3,7 +3,9 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { + getTabIdFromPaneKey, normalizePaneKeySet, + openNonTerminalTabIds, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive, shouldReplaceRetainedWithLive @@ -20,9 +22,13 @@ export function createAgentStatusWorktreeDropActions( let hadLive = false set((s) => { const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( - ([paneKey, entry]) => - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + // An open chat's row is its host's: the chat tab's projection removes it on close. + const chatTabIds = openNonTerminalTabIds(s, worktreeId) + const ownedByWorktree = (paneKey: string, entryWorktreeId: string | undefined): boolean => + !chatTabIds.has(getTabIdFromPaneKey(paneKey) ?? '') && + (entryWorktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)) + const liveEntries = Object.entries(s.agentStatusByPaneKey).filter(([paneKey, entry]) => + ownedByWorktree(paneKey, entry.worktreeId) ) const liveKeys = liveEntries.map(([paneKey]) => paneKey) const liveKeySet = new Set(liveKeys) @@ -30,10 +36,7 @@ export function createAgentStatusWorktreeDropActions( (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) ) const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) - .filter( - ([paneKey, retained]) => - retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) + .filter(([paneKey, retained]) => ownedByWorktree(paneKey, retained.worktreeId)) .map(([paneKey]) => paneKey) const retainedKeySet = new Set(retainedKeys) const migrationUnsupported = pruneMigrationUnsupportedEntries( diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 016a217f2ace..49096e3a5a25 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -237,7 +237,7 @@ export function createBrowserCloseActions( } }, - shutdownWorktreeBrowsers: async (worktreeId) => { + shutdownWorktreeBrowsers: (worktreeId) => { const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] // Why: snapshot before the loop โ€” closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. const hadBrowserTabs = workspaces.length > 0 diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 40175ef8a32f..3ace125b1429 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -149,7 +149,7 @@ export type BrowserSlice = { profileId: string | null ) => Promise closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void - shutdownWorktreeBrowsers: (worktreeId: string) => Promise + shutdownWorktreeBrowsers: (worktreeId: string) => void reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null /** Activation uses the tab's workspace unless a target is supplied; global selection * moves only when that workspace is active. */ diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index 7cb63246aa96..f2a43955bd8d 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -18,7 +18,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' -import type * as RuntimeTerminalStreamModule from '@/runtime/runtime-terminal-stream' +import type * as RemoteRuntimePtyIdModule from '../../../../shared/remote-runtime-pty-id' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -34,8 +34,8 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) -vi.mock('@/runtime/runtime-terminal-stream', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../../shared/remote-runtime-pty-id', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, parseRemoteRuntimePtyId: vi.fn(actual.parseRemoteRuntimePtyId) } }) @@ -48,7 +48,7 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' -import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts index e2c7aee50b14..e3af48123479 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts @@ -181,7 +181,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) }) - it('routes duplicate setup IDs through the first row and replaces every collision', async () => { + it('a setup id shared with another route updates only its own row', async () => { const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local', @@ -205,14 +205,14 @@ export function registerReposProjectHostLifecycleTests(): void { expect(projectsUpdateHostSetup).toHaveBeenCalledWith({ setupId: localSetup.id, + hostId: 'local', updates: { displayName: 'Local renamed' } }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - // Current contract: setup mutations are keyed by bare setup ID after the first row selects routing. - expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, updatedLocalSetup]) + expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) }) - it('routes duplicate setup-ID deletion through the first row and removes every collision', async () => { + it('a setup id shared with another route removes only its own row', async () => { const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local', @@ -228,10 +228,49 @@ export function registerReposProjectHostLifecycleTests(): void { await store.getState().deleteProjectHostSetup({ setupId: localSetup.id }) - expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ setupId: localSetup.id }) + expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ + setupId: localSetup.id, + hostId: 'local' + }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - // Current contract: delete filters the full catalog by bare setup ID. - expect(store.getState().projectHostSetups).toEqual([]) + expect(store.getState().projectHostSetups).toEqual([runtimeSetup]) + }) + + it('refuses a named host the catalog has no setup for instead of deleting by id', async () => { + const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local' } + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [localSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectHostSetup({ setupId: localSetup.id, hostId: 'ssh:gone' }) + ).resolves.toBeNull() + + expect(projectsDeleteHostSetup).not.toHaveBeenCalled() + expect(store.getState().projectHostSetups).toEqual([localSetup]) + }) + + it('names the SSH twin of a local setup on this computer', async () => { + const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local' } + const sshSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'ssh:box' } + projectsDeleteHostSetup.mockResolvedValue({ project, setup: sshSetup }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [localSetup, sshSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await store.getState().deleteProjectHostSetup({ setupId: sshSetup.id, hostId: 'ssh:box' }) + + expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ + setupId: sshSetup.id, + hostId: 'ssh:box' + }) + expect(store.getState().projectHostSetups).toEqual([localSetup]) }) it('preserves runtime-fetched setup-only states during repo hydration', async () => { diff --git a/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts b/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts index ee21460d8c79..55b15c2be23d 100644 --- a/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts +++ b/src/renderer/src/store/slices/terminal-pane-retirement-ownership.test.ts @@ -83,3 +83,27 @@ it.each(['row', 'layout', 'aggregate', 'relay', 'deferred', 'reconnect'] as cons expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(false) } ) + +it('keys a legacy id where spawn put it: exact for an unattributed twin, the runtime for setup', () => { + const twin: TerminalTabRetirementState = { + ...state(), + repos: [ + { id: 'repo', connectionId: null, executionHostId: 'local' }, + { id: 'repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { repo: [{ id: 'wt', repoId: 'repo' }] } + } + expect(getTerminalPtyOwnershipIdentity(twin, 'remote:session', 'wt')).toBe('pty:remote:session') + const focused: TerminalTabRetirementState = { + ...state(), + settings: { activeRuntimeEnvironmentId: 'env-1' }, + runtimeEnvironments: [{ id: 'env-1' }] + } + expect( + getTerminalPtyOwnershipIdentity( + focused, + 'remote:session', + 'ephemeral-setup-terminal:skill-panel' + ) + ).toBe(JSON.stringify(['runtime', 'env-1', 'session'])) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts index 65f796147842..2df722c78a2c 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -4,9 +4,11 @@ import { } from '../../../../shared/remote-runtime-pty-id' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { AppState } from '../types' -import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route' +import { + resolveTerminalHostOwnership, + resolveTerminalPtyOwner +} from '@/lib/terminal-worktree-route' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -61,9 +63,9 @@ export function getTerminalPtyOwnershipIdentity( return JSON.stringify(['runtime', scopedEnvironmentId, remote.handle]) } // Why: hydrated legacy runtime IDs omit their owner, but still refer to the same provider - // terminal as a scoped ID in the owning worktree. This reads the owner the way teardown's - // focus-aware fallback route does, so identity never disagrees with what a close retires. - const owner = resolveRuntimeOwnerForWorktree(state, worktreeId) + // terminal as a scoped ID in the owning worktree. This reads the owner spawn started it on, as + // close does, so identity never disagrees with what a close retires. + const owner = resolveTerminalPtyOwner(state, worktreeId, 'spawn') // Why: a twin's legacy id can't be pinned to either host, so it matches only its own exact id. if (owner.kind === 'ambiguous') { return `pty:${ptyId}` diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts new file mode 100644 index 000000000000..688f0e137a86 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../../types' +import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist' +import { persistWorktreeMeta } from '../worktrees/metadata/worktree-meta-persist' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../shared/workspace-unread-raise-capability' +import { createTestStore, makeWorktree, TEST_REPO } from '../store-test-helpers' +import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures' +import { clearWorkspaceUnreadFlag, markWorkspaceUnreadFlag } from './workspace-unread-flag' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' + +// With the switch on, the window raises the flag exactly where the owning host does not. + +const hostCapabilities = vi.hoisted(() => new Map>()) +const cachedStatus = vi.hoisted(() => new Map()) + +vi.mock('../../../../../shared/sidebar-inputs-switch', () => ({ + ORCA_SIDEBAR_INPUTS_IS_MAIN: true +})) +vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) } +}) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + // Null before the environment's first status probe, as the real cache. + readCachedRuntimeEnvironmentCapability: (environmentId: string, capability: string) => + cachedStatus.get(environmentId)?.includes(capability) ?? null, + // Settles with the environment's status probe; rejects when it cannot be read. + runtimeEnvironmentSupportsCapability: vi.fn(async (environmentId: string, capability: string) => { + const capabilities = await hostCapabilities.get(environmentId) + if (!capabilities) { + throw new Error('runtime unreachable') + } + return capabilities.includes(capability) + }) +})) +vi.mock('../worktrees/listing/worktree-owner-target', () => ({ + // `/server-/` paths and `runtime:` hosts belong to that paired server; the rest to this app. + tryRuntimeTargetForWorktreeOwner: (_state: unknown, workspaceId: string, hostId?: string) => { + const environmentId = + hostId?.replace(/^runtime:/, '') ?? /\/server-([^/]+)\//.exec(workspaceId)?.[1] + return environmentId && environmentId !== 'local' + ? { kind: 'environment', environmentId } + : { kind: 'local' } + } +})) + +const LOCAL_WT = 'repo1::/path/local/a' +const NEW_SERVER_WT = 'repo1::/path/server-new/a' +const OLD_SERVER_WT = 'repo1::/path/server-old/a' +const SLOW_SERVER_WT = 'repo1::/path/server-slow/a' +const UNREACHABLE_SERVER_WT = 'repo1::/path/server-unreachable/a' +const FOLDER_ID = 'folder-1' + +function createStore() { + const store = createTestStore() + const updateFolderWorkspace = vi.fn(async () => true) + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'local' }], + worktreesByRepo: { + repo1: [LOCAL_WT, NEW_SERVER_WT, OLD_SERVER_WT, SLOW_SERVER_WT, UNREACHABLE_SERVER_WT].map( + (id) => makeWorktree({ id, repoId: 'repo1', path: id.replace('repo1::', '') }) + ) + }, + folderWorkspaces: [ + makeFolderWorkspace({ id: FOLDER_ID, executionHostId: 'runtime:old' }), + makeFolderWorkspace({ id: FOLDER_ID, executionHostId: 'runtime:new' }) + ], + updateFolderWorkspace + }) + const mark = async (id: string): Promise => { + markWorkspaceUnreadFlag(store.setState, store.getState, id) + // The decision settles after the host's status answers. + await new Promise((resolve) => setTimeout(resolve, 0)) + } + const isUnread = (id: string): boolean => + Object.values(store.getState().worktreesByRepo) + .flat() + .some((worktree) => worktree.id === id && worktree.isUnread) + return { store, mark, isUnread, updateFolderWorkspace } +} + +beforeEach(() => { + vi.clearAllMocks() + hostCapabilities.clear() + cachedStatus.clear() + hostCapabilities.set('new', Promise.resolve([WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY])) + hostCapabilities.set('old', Promise.resolve(['runtime.status.compat.v1'])) +}) + +describe('the window raises the unread flag only where its host does not', () => { + it('leaves it to this appโ€™s own main, which covers its local and SSH workspaces', async () => { + const { mark, isUnread } = createStore() + await mark(LOCAL_WT) + expect(isUnread(LOCAL_WT)).toBe(false) + expect(persistWorktreeMeta).not.toHaveBeenCalled() + }) + + it('leaves it to a paired server that raises it', async () => { + const { mark, isUnread } = createStore() + await mark(NEW_SERVER_WT) + expect(isUnread(NEW_SERVER_WT)).toBe(false) + expect(persistWorktreeMeta).not.toHaveBeenCalled() + }) + + it('raises it on an older paired server, writing to that server', async () => { + const { mark, isUnread } = createStore() + await mark(OLD_SERVER_WT) + expect(isUnread(OLD_SERVER_WT)).toBe(true) + expect(persistWorktreeMeta).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'old' }, + OLD_SERVER_WT, + expect.objectContaining({ isUnread: true }) + ) + }) + + it('answers from the cached status without probing again', async () => { + const { mark, isUnread } = createStore() + cachedStatus.set('slow', [WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY]) + await mark(SLOW_SERVER_WT) + expect(isUnread(SLOW_SERVER_WT)).toBe(false) + cachedStatus.set('slow', []) + await mark(SLOW_SERVER_WT) + expect(isUnread(SLOW_SERVER_WT)).toBe(true) + expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled() + }) + + it('waits for a serverโ€™s first status, and raises it when the status cannot be read', async () => { + const { mark, isUnread } = createStore() + let answer: (capabilities: readonly string[]) => void = () => {} + hostCapabilities.set('slow', new Promise((resolve) => (answer = resolve))) + await mark(SLOW_SERVER_WT) + expect(isUnread(SLOW_SERVER_WT)).toBe(false) + answer([]) + await vi.waitFor(() => expect(isUnread(SLOW_SERVER_WT)).toBe(true)) + + await mark(UNREACHABLE_SERVER_WT) + expect(isUnread(UNREACHABLE_SERVER_WT)).toBe(true) + }) + + it('decides a folder workspace by the host of the row it raises', async () => { + const { store, mark, updateFolderWorkspace } = createStore() + const folder = folderWorkspaceKey(FOLDER_ID) + store.setState({ activeWorktreeId: folder, activeWorkspaceExecutionHostId: 'runtime:new' }) + await mark(folder) + expect(updateFolderWorkspace).not.toHaveBeenCalled() + + store.setState({ activeWorkspaceExecutionHostId: 'runtime:old' }) + await mark(folder) + expect(updateFolderWorkspace).toHaveBeenCalledWith( + FOLDER_ID, + expect.objectContaining({ isUnread: true }), + { executionHostId: 'runtime:old' } + ) + }) + + it('clears a flag on every owner, whichever side raised it', () => { + const { store, isUnread } = createStore() + for (const id of [LOCAL_WT, NEW_SERVER_WT, OLD_SERVER_WT]) { + store.setState((s) => ({ + worktreesByRepo: { + repo1: s.worktreesByRepo.repo1.map((worktree) => + worktree.id === id ? { ...worktree, isUnread: true } : worktree + ) + } + })) + clearWorkspaceUnreadFlag(store.setState, store.getState, id) + expect(isUnread(id)).toBe(false) + expect(persistWorktreeMeta).toHaveBeenLastCalledWith(expect.anything(), id, { + isUnread: false + }) + } + }) +}) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts index 428ce99e460d..7409a6254910 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag.ts @@ -12,6 +12,12 @@ import { persistPassiveWorktreeMetaForOwner } from '../worktrees/listing/worktre import type { WorkspaceAttentionGet, WorkspaceAttentionSet } from './workspace-attention-contract' import { workspaceHasLiveMarker } from './workspace-live-markers' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../shared/sidebar-inputs-switch' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from '../../../../../shared/workspace-unread-raise-capability' +import { + readCachedRuntimeEnvironmentCapability, + runtimeEnvironmentSupportsCapability +} from '@/runtime/runtime-rpc-client' +import { tryRuntimeTargetForWorktreeOwner } from '../worktrees/listing/worktree-owner-target' // The persisted workspace flag `isUnread`. Only the workspace-attention module writes it. @@ -20,21 +26,79 @@ export function markWorkspaceUnreadFlag( get: WorkspaceAttentionGet, worktreeId: string ): void { - // Switch point: the owning host raises the flag from the same news. - if (ORCA_SIDEBAR_INPUTS_IS_MAIN) { + if (!ORCA_SIDEBAR_INPUTS_IS_MAIN) { + raiseWorkspaceUnreadFlag(set, get, worktreeId) return } + // Switch point: the owning host raises the flag from the same news, unless it is older. + void ownerRaisesUnreadFlag(get(), worktreeId).then((ownerRaises) => { + if (!ownerRaises) { + raiseWorkspaceUnreadFlag(set, get, worktreeId) + } + }) +} + +/** + * Whether the owning host raises the flag itself. A local owner runs this build (main, its SSH + * hosts, or the host that served the browser client). A paired server answers by capability; an + * unreadable status raises here, so no flag is lost. + */ +async function ownerRaisesUnreadFlag(state: AppState, worktreeId: string): Promise { + const workspaceScope = parseWorkspaceKey(worktreeId) + const folderRow = + workspaceScope?.type === 'folder' + ? folderRowToMark(state, worktreeId, workspaceScope.folderWorkspaceId) + : undefined + const target = tryRuntimeTargetForWorktreeOwner( + state, + worktreeId, + folderRow ? getFolderWorkspaceHostId(folderRow, state.projectGroups) : undefined + ) + if (!target) { + return false + } + if (target.kind === 'local') { + return true + } + // Why the cached answer first: a per-raise probe would delay the flag past a screen arrival. + const cached = readCachedRuntimeEnvironmentCapability( + target.environmentId, + WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY + ) + if (cached !== null) { + return cached + } + return runtimeEnvironmentSupportsCapability( + target.environmentId, + WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY + ).catch(() => false) +} + +/** Same-id folder rows: the on-screen host's, else the first. */ +function folderRowToMark( + state: AppState, + worktreeId: string, + folderWorkspaceId: string +): FolderWorkspace | undefined { + const host = onScreenHostId(state, worktreeId) + return state.folderWorkspaces.find( + (workspace) => + workspace.id === folderWorkspaceId && + (!host || getFolderWorkspaceHostId(workspace, state.projectGroups) === host) + ) +} + +function raiseWorkspaceUnreadFlag( + set: WorkspaceAttentionSet, + get: WorkspaceAttentionGet, + worktreeId: string +): void { const stamp = { lastActivityAt: Date.now() } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId const state = get() - const host = onScreenHostId(state, worktreeId) - const marked = state.folderWorkspaces.find( - (workspace) => - workspace.id === folderWorkspaceId && - (!host || getFolderWorkspaceHostId(workspace, state.projectGroups) === host) - ) + const marked = folderRowToMark(state, worktreeId, folderWorkspaceId) if (!marked || marked.isUnread) { return } diff --git a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts index b363a5f8486d..79927eadddc0 100644 --- a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts +++ b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts @@ -201,7 +201,10 @@ describe('removeWorktree state cleanup', () => { // Only the orphaned runtime-owned project setup is purged; the user's SSH project is untouched. expect(deleteProjectHostSetup).toHaveBeenCalledTimes(1) - expect(deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-runtime-ssh' }) + expect(deleteProjectHostSetup).toHaveBeenCalledWith({ + setupId: 'setup-runtime-ssh', + hostId: 'ssh:runtime-ssh-orca-1' + }) }) it('cleans up editorDrafts for files in the removed worktree', async () => { diff --git a/src/renderer/src/store/slices/worktrees/host-scoped-row-merges.test.ts b/src/renderer/src/store/slices/worktrees/host-scoped-row-merges.test.ts index 3743a541c4b9..da2ad0d1da53 100644 --- a/src/renderer/src/store/slices/worktrees/host-scoped-row-merges.test.ts +++ b/src/renderer/src/store/slices/worktrees/host-scoped-row-merges.test.ts @@ -89,4 +89,20 @@ describe('row writers touch only the copy on their own host', () => { hostId: 'runtime:hub' }) }) + + it("never appends a server's copy when only the local twin is listed", () => { + const store = createTestStore() + const { local } = seedTwins(store) + store.setState({ worktreesByRepo: { [REPO_ID]: [local] } }) + applyWorktreeLineageUpdate(store.setState, WORKTREE_ID, { + target: { kind: 'environment', environmentId: 'hub' }, + lineage: null, + updatedRemoteWorktree: makeWorktree({ + id: WORKTREE_ID, + repoId: REPO_ID, + displayName: 'server after lineage' + }) + }) + expect(store.getState().worktreesByRepo[REPO_ID]).toEqual([local]) + }) }) diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts index 62a4eabc75ec..dc309a6dbf31 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts @@ -189,18 +189,16 @@ export function applyWorktreeLineageUpdate( } else { delete next[worktreeId] } + // Why the transport's host: focus can name the local twin of a row a server just answered for. + const transportHostId = getRuntimeTargetHostId(result.target) const worktreesByRepo = result.target.kind === 'local' ? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage) : result.updatedRemoteWorktree - ? // Why the transport's host: focus can name the local twin of a row a server just answered for. - replaceWorktreeInRepoLists( + ? replaceWorktreeInRepoLists( s, - withRepoHostOwnership( - result.updatedRemoteWorktree, - getRuntimeTargetHostId(result.target) - ), - getRuntimeTargetHostId(result.target) + withRepoHostOwnership(result.updatedRemoteWorktree, transportHostId), + transportHostId ) : s.worktreesByRepo return { diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index c2efa2330efd..4e271dd0b217 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -95,6 +95,11 @@ function resolvePurgedPublishers( } } +/** Every host whose listing publishes this id; empty when no row names it. */ +export function workspacePublishingHosts(s: AppState, workspaceId: string): ExecutionHostId[] { + return resolvePurgedPublishers(s, workspaceId, new Set()).survivors +} + /** Per attributed host, its twin ids with the hosts that still publish them. */ export type HostTwinPurgeScopes = Map> diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts index 023889faaf4c..db710bc47219 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts @@ -19,13 +19,13 @@ export async function purgeOrphanedRuntimeSshProjects( const destroyedHostIds = new Set( purgeableSshTargetIds.map((id) => toSshExecutionHostId(id)) ) - const orphanedSetupIds = get() + const orphanedSetups = get() .projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) - .map((setup) => setup.id) + .map((setup) => ({ setupId: setup.id, hostId: setup.hostId })) const purgedRepoIds = new Set() - for (const setupId of orphanedSetupIds) { + for (const target of orphanedSetups) { try { - const result = await get().deleteProjectHostSetup({ setupId }) + const result = await get().deleteProjectHostSetup(target) if (result?.repo) { purgedRepoIds.add(result.repo.id) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 4a4c0a4a2e6f..02f1bb57f296 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -58,7 +58,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { } // Why: renderer state follows the successful backend result, so blocked dirty deletes keep their terminals intact. // Why browsers first: unregister Chromium guests before other teardown can intercept them (avoids a browser-state race). - await get().shutdownWorktreeBrowsers(worktreeId) + await Promise.resolve(get().shutdownWorktreeBrowsers(worktreeId)) await get().shutdownWorktreeTerminals(worktreeId, { shutdownReason: 'remove-worktree', // The backend removal above already killed the workspace's PTYs. diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 5ebd99bbd597..43c63c9883a6 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -148,6 +148,7 @@ export type TerminalActions = { sleepingPaneKeys?: string[] expectedRuntimePtyIds?: string[] backendOwnsPtyTeardown?: boolean + onAdmitted?: () => void } ) => Promise /** Captures resumable pane evidence and publishes suppression before exact physical teardown. */ diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 39bf2fc372c1..dc052b80a5e0 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -13,7 +13,7 @@ import { } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' -import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveTerminalPtyOwner } from '@/lib/terminal-worktree-route' import { resolveTerminalTeardownOwner } from './terminal-teardown-owner' import { removePanesAttention } from '../slices/workspace-attention/subject-attention-transitions' import { settleAttentionRemovedBy } from '../slices/workspace-attention/attention-removal-follow-up' @@ -32,7 +32,7 @@ export function createTerminalPaneHibernationActions( ) const rendererShutdownPtyIds = [opts.ptyId] const state = get() - const worktreeOwner = resolveRuntimeOwnerForWorktree(state, worktreeId) + const worktreeOwner = resolveTerminalPtyOwner(state, worktreeId) const teardownOwner = resolveTerminalTeardownOwner(worktreeOwner, [opts.ptyId]) // Why: a twin-published worktree must route by the PTY's own owner; a remote PTY with no // host stop to run would otherwise lose its binding while the agent keeps running. diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index b6ac61c8e9f7..b0f189ae604d 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -1,5 +1,8 @@ -import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' -import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId, + toRemoteRuntimePtyId +} from '../../../../shared/remote-runtime-pty-id' import { isTerminalTabPresent } from '../slices/terminal-tab-retirement' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index 7ce850f344de..68bfd8488e82 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -9,7 +9,7 @@ import { } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' -import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveTerminalPtyOwner } from '@/lib/terminal-worktree-route' import { resolveTerminalTeardownOwner } from './terminal-teardown-owner' import { createTerminalShutdownGuardController } from './terminal-shutdown-guards' import { commitTerminalShutdownState } from './terminal-shutdown-state' @@ -39,7 +39,7 @@ export function createTerminalShutdownActions( const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds) const backendOwnsPtyTeardown = opts?.backendOwnsPtyTeardown === true const teardownOwner = resolveTerminalTeardownOwner( - resolveRuntimeOwnerForWorktree(get(), worktreeId), + resolveTerminalPtyOwner(get(), worktreeId), rendererShutdownPtyIds ) // Why: treating an unattributable owner as local skips the host stop yet still clears @@ -101,6 +101,7 @@ export function createTerminalShutdownActions( guards.markShutdownPending() guards.unregisterHandlers() try { + opts?.onAdmitted?.() // Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks. if (runtimeEnvironmentId && !backendOwnsPtyTeardown) { await (shutdownReason === 'manual-sleep' diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.test.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.test.ts new file mode 100644 index 000000000000..82c778efa13e --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route' +import { createTestStore } from '../slices/store-test-helpers' +import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' + +const mocks = vi.hoisted(() => { + const ptyOwner: { current: TerminalPtyOwner } = { current: { kind: 'non-runtime' } } + return { ptyOwner, callRuntimeRpc: vi.fn(async () => undefined) } +}) + +vi.mock('@/lib/terminal-worktree-route', () => ({ + resolveTerminalPtyOwner: () => mocks.ptyOwner.current, + resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: 'env-a' }) +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + ensureRuntimeEnvironmentCompatible: vi.fn() +})) + +function retire(): void { + startTerminalTabProviderRetirement({ + localPtyTeardownOwnedExternally: false, + remoteCloseOwnedByHost: false, + state: createTestStore().getState(), + tabId: 'tab-1', + retirementPlan: { + tabId: 'tab-1', + worktreeId: 'repo::wt', + ptyIds: ['remote:handle-1'], + localOrSshPtyIds: [], + runtimeTerminals: [{ ptyId: 'remote:handle-1', environmentId: null, handle: 'handle-1' }], + cleanupOnlyPtyIds: [], + sharedPtyIds: [], + unroutablePtyIds: [] + } + }) +} + +describe('closing an unscoped remote terminal id', () => { + let warn: ReturnType + beforeEach(() => { + mocks.callRuntimeRpc.mockClear() + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => warn.mockRestore()) + + it('closes it where spawn started it', () => { + mocks.ptyOwner.current = { kind: 'runtime', environmentId: 'env-a' } + retire() + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-a' }, + 'terminal.close', + { terminal: 'handle-1' } + ) + }) + + it('skips and logs it, never closing on this app, when the owner is ambiguous', () => { + mocks.ptyOwner.current = { kind: 'ambiguous' } + retire() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[terminal-retirement] skipped PTYs with no resolvable owner', + { + tabId: 'tab-1', + worktreeKind: expect.any(String), + count: 1 + } + ) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts index 4ba425d95d20..03f4f7ddac50 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts @@ -4,7 +4,10 @@ import { } from '@/runtime/runtime-environment-revision' import type { AppState } from '../types' import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { + resolveTerminalPtyOwner, + resolveTerminalWorktreeRoute +} from '@/lib/terminal-worktree-route' import { classifyTerminalRetirementWorktree, type TerminalTabRetirementPlan @@ -25,16 +28,22 @@ export function startTerminalTabProviderRetirement({ tabId: string canRetireRuntimeTerminal?: () => boolean }): void { - const fallbackWorktreeRoute = retirementPlan.worktreeId - ? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId) - : { runtimeEnvironmentId: null } + const { worktreeId } = retirementPlan + // Why: an unscoped `remote:` id of an unattributable twin must not be closed on this app. + const fallbackWorktreeRoute = !worktreeId + ? { runtimeEnvironmentId: null } + : resolveTerminalPtyOwner(state, worktreeId, 'spawn').kind === 'ambiguous' + ? null + : resolveTerminalWorktreeRoute(state, worktreeId) const retirementTasks: Promise[] = localPtyTeardownOwnedExternally ? [] : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) const localOrSshTaskCount = retirementTasks.length + let unroutableRuntimeTerminalCount = 0 if (!remoteCloseOwnedByHost) { for (const terminal of retirementPlan.runtimeTerminals) { if (!terminal.environmentId && !fallbackWorktreeRoute) { + unroutableRuntimeTerminalCount += 1 continue } const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId @@ -43,12 +52,13 @@ export function startTerminalTabProviderRetirement({ ) } } - if (retirementPlan.unroutablePtyIds.length > 0) { + const unroutableCount = retirementPlan.unroutablePtyIds.length + unroutableRuntimeTerminalCount + if (unroutableCount > 0) { // Log the worktree shape, never its id, because worktree ids embed absolute paths. console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', { tabId, worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId), - count: retirementPlan.unroutablePtyIds.length + count: unroutableCount }) } // Close remains synchronous; provider failures cannot block ownership revocation. diff --git a/src/renderer/src/store/terminals/terminal-teardown-owner.ts b/src/renderer/src/store/terminals/terminal-teardown-owner.ts index b2c1c9fc49a1..21d3f32682ea 100644 --- a/src/renderer/src/store/terminals/terminal-teardown-owner.ts +++ b/src/renderer/src/store/terminals/terminal-teardown-owner.ts @@ -2,7 +2,7 @@ import { isRemoteRuntimePtyId, parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' -import type { WorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' +import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route' export type TerminalTeardownOwner = | { kind: 'resolved'; runtimeEnvironmentId: string | null } @@ -14,7 +14,7 @@ export type TerminalTeardownOwner = * attributed, so teardown is refused rather than treated as local. */ export function resolveTerminalTeardownOwner( - worktreeOwner: WorktreeRuntimeOwner, + worktreeOwner: TerminalPtyOwner, ptyIds: readonly string[] ): TerminalTeardownOwner { if (worktreeOwner.kind === 'runtime') { diff --git a/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts index 1a4ef660acea..17ceee080db6 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-placeholders.ts @@ -4,9 +4,9 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../../../shared/constants' import { getRepoExecutionHostId, parseExecutionHostId, - toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { repoRowOwnerStamp } from './workspace-terminal-ssh-placeholders' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' @@ -16,22 +16,13 @@ export function getPathDisplayName(path: string, fallback: string): string { return basename || fallback } -/** - * A server's repo that nests an SSH target lists its worktrees as `ssh:` reached through - * that server; a bare `runtime:` stamp would hide the target from an `ssh:` host selection. - */ function runtimePlaceholderOwner( repos: readonly Repo[], repoId: string, hostId: ExecutionHostId ): Pick { - const host = parseExecutionHostId(hostId) - const connectionId = repos - .find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) - ?.connectionId?.trim() - return connectionId && host?.kind === 'runtime' - ? { hostId: toSshExecutionHostId(connectionId), runtimeOwnerEnvironmentId: host.environmentId } - : { hostId } + const row = repos.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) + return row ? repoRowOwnerStamp(row) : { hostId } } export function buildRuntimeSessionPlaceholders({ diff --git a/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts index b7b3b37c3264..8a4ee529318b 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-ssh-placeholders.ts @@ -13,23 +13,33 @@ import { type PlaceholderOwner = Pick +/** + * The owner a listing stamps on this repo row's worktrees. A server's repo that nests an SSH + * target lists them as `ssh:` through that server, so a bare `runtime:` stamp would hide + * the target from an `ssh:` host selection. + */ +export function repoRowOwnerStamp(row: Repo): PlaceholderOwner { + const host = parseExecutionHostId(getRepoExecutionHostId(row)) + const connectionId = row.connectionId?.trim() + if (host?.kind === 'runtime') { + return connectionId + ? { + hostId: toSshExecutionHostId(connectionId), + runtimeOwnerEnvironmentId: host.environmentId + } + : { hostId: host.id } + } + // Why: a row whose explicit host disagrees with its connection is not evidence of either. + return connectionId && host?.id === toSshExecutionHostId(connectionId) ? { hostId: host.id } : {} +} + /** * The SSH host, and the server proxying it, that a listing would stamp. A repo id with rows on * several hosts names neither, so its placeholder stays unstamped rather than guessing a twin. */ function placeholderOwner(repos: readonly Repo[], repoId: string): PlaceholderOwner { const rows = repos.filter((repo) => repo.id === repoId) - const connectionId = rows.length === 1 ? rows[0].connectionId?.trim() : undefined - if (!connectionId) { - return {} - } - const sshHostId = toSshExecutionHostId(connectionId) - const host = parseExecutionHostId(getRepoExecutionHostId(rows[0])) - if (host?.kind === 'runtime') { - return { hostId: sshHostId, runtimeOwnerEnvironmentId: host.environmentId } - } - // Why: a row whose explicit host disagrees with its connection is not evidence of either. - return host?.id === sshHostId ? { hostId: sshHostId } : {} + return rows.length === 1 && rows[0].connectionId?.trim() ? repoRowOwnerStamp(rows[0]) : {} } export function addHydratedSshWorktreePlaceholders( diff --git a/src/renderer/src/web/preload-api/web-filesystem-api-connection-pin.test.ts b/src/renderer/src/web/preload-api/web-filesystem-api-connection-pin.test.ts new file mode 100644 index 000000000000..ebc9dc35b5f6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-filesystem-api-connection-pin.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' + +const session = vi.hoisted(() => { + const client = { connectionEpoch: 1, call: vi.fn() } + const environment = { id: 'env-1', createdAt: 1 } + return { client, environment, state: { activeClient: client } } +}) + +vi.mock('./web-runtime-session', () => ({ + getClientForEnvironment: () => session.client, + requireActiveEnvironment: () => session.environment, + requireActiveEnvironmentOrNull: () => session.environment, + runtimeCallQueuePool: { enqueue: (_id: string, _method: string, run: () => unknown) => run() }, + updateEnvironmentFromResponse: () => undefined, + webRuntimeState: session.state +})) +vi.mock('./web-runtime-worktree-catalog', () => ({ + isMissingPathError: () => false, + resolveRuntimeFilePath: async () => ({ + worktree: { id: 'wt-1', hostId: 'local' }, + relativePath: 'readme.md' + }) +})) + +import { createFileApi } from './web-filesystem-api' + +describe('paired web client writes after the client reconnects during the support probe', () => { + beforeEach(() => { + session.client.connectionEpoch = 1 + session.client.call.mockReset() + session.client.call.mockImplementation(async (method: string) => { + if (method === 'status.get') { + // The same client reconnects while the probe is in flight. + session.client.connectionEpoch += 1 + return { + id: 'rpc', + ok: true, + result: { + capabilities: [ + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + 'files.write.disk-precondition.v1' + ] + } + } + } + return { id: 'rpc', ok: true, result: { ok: true } } + }) + }) + + it('sends a plain save as before, unpinned', async () => { + await createFileApi().writeFile({ filePath: '/hub/readme.md', content: 'edited' }) + + expect(session.client.call).toHaveBeenLastCalledWith('files.write', expect.anything(), { + timeoutMs: undefined, + expectedConnectionEpoch: undefined + }) + }) + + it('refuses to send a guarded save whose probe answered on another connection', async () => { + await expect( + createFileApi().writeFile({ + filePath: '/hub/readme.md', + content: 'edited', + diskPrecondition: { expectedSignature: getDiskBaselineSignature('original') } + }) + ).rejects.toThrow() + expect(session.client.call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index e11ac26b0eb1..4d826a802b17 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -9,6 +9,7 @@ import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { SshConnectionState } from '../../../../shared/ssh-types' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { createWebFileMutationMethods } from '../web-file-mutation-methods' +import { RUNTIME_CONNECTION_CHANGED_MESSAGE } from '../web-runtime-request-registry' import { callRuntimeResult } from './web-runtime-calls' import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' import { @@ -177,7 +178,7 @@ export function createFileApi(): NonNullable['fs']> { export function captureWebFileMutationSession(): { resolveFilePath: (filePath: string) => Promise>> - assertMutationSupported: () => Promise + assertMutationSupported: (options: { pinConnection: boolean }) => Promise callRuntimeResult: WebRuntimeResultCaller getSshState: (targetId: string) => Promise } { @@ -191,6 +192,7 @@ export function captureWebFileMutationSession(): { throw new Error('Runtime pairing changed; refresh and try again') } } + let probedEpoch: number | undefined const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( method: string, params?: unknown, @@ -199,7 +201,7 @@ export function captureWebFileMutationSession(): { assertCurrent() const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { assertCurrent() - return client.call(method, params, { timeoutMs }) + return client.call(method, params, { timeoutMs, expectedConnectionEpoch: probedEpoch }) }) assertCurrent() updateEnvironmentFromResponse(environment, response) @@ -226,10 +228,19 @@ export function captureWebFileMutationSession(): { false, environment.id ), - assertMutationSupported: async () => { - assertFileMutationOwnershipCapability( - await callBoundRuntimeResult('status.get', undefined, 15_000) - ) + assertMutationSupported: async ({ pinConnection }) => { + const epochBeforeProbe = client.connectionEpoch + const status = await callBoundRuntimeResult('status.get', undefined, 15_000) + assertFileMutationOwnershipCapability(status) + if (!pinConnection) { + return status.capabilities ?? [] + } + // Why: the same client reconnects internally; a guarded write must reach the runtime that answered. + if (client.connectionEpoch !== epochBeforeProbe) { + throw new Error(RUNTIME_CONNECTION_CHANGED_MESSAGE) + } + probedEpoch = epochBeforeProbe + return status.capabilities ?? [] }, callRuntimeResult: callBoundRuntimeResult, getSshState: async (targetId) => diff --git a/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts b/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts new file mode 100644 index 000000000000..c72e643bc056 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-calls-connection-pin.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const session = vi.hoisted(() => { + const client = { + connectionEpoch: 1, + runtimeId: 'runtime-a', + call: vi.fn() + } + return { client, environment: { id: 'env-1', createdAt: 1 } } +}) + +vi.mock('./web-runtime-session', () => ({ + getClientForEnvironment: () => session.client, + manuallyDisconnectedEnvironmentIds: new Set(), + manuallyDisconnectedResponse: () => ({ ok: false }), + requireActiveEnvironment: () => session.environment, + resolveEnvironment: () => session.environment, + runtimeCallQueuePool: { enqueue: (_id: string, _method: string, run: () => unknown) => run() }, + updateEnvironmentFromResponse: () => undefined +})) + +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { RUNTIME_CONNECTION_CHANGED_MESSAGE } from '../web-runtime-request-registry' + +describe('web runtime call pinned to the probed runtime', () => { + beforeEach(() => { + session.client.connectionEpoch = 1 + session.client.runtimeId = 'runtime-a' + session.client.call.mockReset() + session.client.call.mockImplementation(async () => ({ + id: 'rpc', + ok: true, + result: {}, + _meta: { runtimeId: session.client.runtimeId } + })) + }) + + it('sends a runtime-pinned write only on the connection where that runtime answered', async () => { + await callEnvironmentEnvelope('env-1', 'status.get') + // The same client reconnects to an older server before the write is sent. + session.client.connectionEpoch = 2 + session.client.runtimeId = 'runtime-b' + + await callEnvironmentEnvelope('env-1', 'files.write', {}, 15_000, undefined, 'runtime-a') + + expect(session.client.call).toHaveBeenLastCalledWith( + 'files.write', + {}, + { + timeoutMs: 15_000, + expectedConnectionEpoch: 1 + } + ) + }) + + it('refuses a runtime-pinned write that no probe on this client vouches for', async () => { + await expect( + callEnvironmentEnvelope('env-1', 'files.write', {}, 15_000, undefined, 'runtime-never-probed') + ).rejects.toThrow(RUNTIME_CONNECTION_CHANGED_MESSAGE) + expect(session.client.call).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index 995bf58b5e6d..0447eed954dc 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -13,6 +13,7 @@ import { runtimeCallQueuePool, updateEnvironmentFromResponse } from './web-runtime-session' +import { RUNTIME_CONNECTION_CHANGED_MESSAGE } from '../web-runtime-request-registry' export type WebRuntimeResultCaller = ( method: string, @@ -58,12 +59,60 @@ export function assertEnvironmentPairing(selector: string, expectedRevision?: nu } } +/** Per client (epochs restart with each client): the connection whose runtime last answered a status probe. */ +const probedConnections = new WeakMap() + +function responseRuntimeId(response: unknown): string | undefined { + if (typeof response !== 'object' || response === null || !('_meta' in response)) { + return undefined + } + const meta: unknown = response._meta + return typeof meta === 'object' && meta !== null && 'runtimeId' in meta + ? typeof meta.runtimeId === 'string' + ? meta.runtimeId + : undefined + : undefined +} + +/** + * Why: a request shaped for one runtime's capabilities must reach that runtime. The same client + * reconnects internally, so pin it to the connection the matching status probe ran on; with no + * such probe, refuse so the caller re-probes. + */ +function callPinnedToRuntime( + client: ReturnType, + method: string, + params: unknown, + timeoutMs: number | undefined, + expectedEnvironmentRuntimeId: string | undefined +): Promise> { + if (method === 'status.get') { + const epoch = client.connectionEpoch + return client.call(method, params, { timeoutMs }).then((response) => { + const runtimeId = responseRuntimeId(response) + if (response.ok && runtimeId && client.connectionEpoch === epoch) { + probedConnections.set(client, { epoch, runtimeId }) + } + return response + }) + } + if (expectedEnvironmentRuntimeId === undefined) { + return client.call(method, params, { timeoutMs }) + } + const probed = probedConnections.get(client) + if (probed?.runtimeId !== expectedEnvironmentRuntimeId) { + return Promise.reject(new Error(RUNTIME_CONNECTION_CHANGED_MESSAGE)) + } + return client.call(method, params, { timeoutMs, expectedConnectionEpoch: probed.epoch }) +} + export async function callEnvironmentEnvelope( selector: string, method: string, params?: unknown, timeoutMs?: number, - expectedEnvironmentPairingRevision?: number + expectedEnvironmentPairingRevision?: number, + expectedEnvironmentRuntimeId?: string ): Promise> { const environment = resolveEnvironment(selector) const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) @@ -76,7 +125,13 @@ export async function callEnvironmentEnvelope( if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return Promise.resolve(manuallyDisconnectedResponse(environment)) } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + return callPinnedToRuntime( + getClientForEnvironment(environment), + method, + params, + timeoutMs, + expectedEnvironmentRuntimeId + ) }) if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return manuallyDisconnectedResponse(environment) diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts index 01a77188a506..506810d00bde 100644 --- a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -212,13 +212,21 @@ export function createRuntimeEnvironmentsApi(): NonNullable< : callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), retryControlConnection: () => Promise.resolve(), prepareBrowserClientHostPlacement: async () => ({ kind: 'server' }), - call: ({ selector, method, params, timeoutMs, expectedEnvironmentPairingRevision }) => + call: ({ + selector, + method, + params, + timeoutMs, + expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId + }) => callEnvironmentEnvelope( selector, method, params, timeoutMs, - expectedEnvironmentPairingRevision + expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId ), cancelSubscription: async ({ subscriptionId }) => { pendingSubscriptions.get(subscriptionId)?.abort() diff --git a/src/renderer/src/web/web-file-mutation-methods.test.ts b/src/renderer/src/web/web-file-mutation-methods.test.ts index 266beec9fb50..26326ffda953 100644 --- a/src/renderer/src/web/web-file-mutation-methods.test.ts +++ b/src/renderer/src/web/web-file-mutation-methods.test.ts @@ -22,7 +22,7 @@ function connectedSshState(targetId: string, connectionGeneration: number): SshC } describe('paired web file mutation methods', () => { - const assertMutationSupported = vi.fn(async () => {}) + const assertMutationSupported = vi.fn(async (): Promise => []) const callRuntimeResult = vi.fn(async () => ({ ok: true })) const getSshState = vi.fn(async () => connectedSshState('hub-private-target', 17)) const filesByPath = new Map([ @@ -285,14 +285,15 @@ describe('paired web file mutation methods', () => { it('keeps path resolution, capability, and mutation on one captured pairing session', async () => { const replacementCall = vi.fn(async () => ({ ok: true })) const replacementSession = { - assertMutationSupported: vi.fn(async () => {}), + assertMutationSupported: vi.fn(async (): Promise => []), callRuntimeResult: replacementCall, getSshState, resolveFilePath } const capturedSession = { - assertMutationSupported: vi.fn(async () => { + assertMutationSupported: vi.fn(async (): Promise => { captureSession.mockReturnValue(replacementSession) + return [] }), callRuntimeResult, getSshState, diff --git a/src/renderer/src/web/web-file-mutation-methods.ts b/src/renderer/src/web/web-file-mutation-methods.ts index 262b7e30fc95..fd569d16ce54 100644 --- a/src/renderer/src/web/web-file-mutation-methods.ts +++ b/src/renderer/src/web/web-file-mutation-methods.ts @@ -3,6 +3,19 @@ import { parseExecutionHostId } from '../../../shared/execution-host' import type { SshConnectionState, SshMutationExpectation } from '../../../shared/ssh-types' import type { Worktree } from '../../../shared/worktree/types' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' +import type { RuntimeFileSnapshot } from '../runtime/runtime-file-range-client' +import { pagedTextFileResult, readFileInChunks } from '../runtime/runtime-paged-file-read' +import type { RuntimeFileReadChunkResult } from '../../../shared/runtime-types' +import { + compareDiskWritePrecondition, + diskConflictFromReadFailure, + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, + FILE_WRITE_IF_UNCHANGED_METHOD, + parseFileWriteResult, + type FileWriteDiskConflict, + type FileWriteDiskPrecondition, + type FileWriteResult +} from '../../../shared/file-write-disk-precondition' const SSH_OWNER_CHANGED_MESSAGE = "Couldn't verify the SSH connection. Reconnect the host and try again." @@ -19,7 +32,11 @@ type ResolvedWebRuntimeFile = { type WebFileMutationSession = { resolveFilePath: (filePath: string) => Promise - assertMutationSupported: () => Promise + /** + * Resolves to the host's advertised capabilities. `pinConnection` sends the session's later + * calls only over the connection that answered, for writes whose choice depends on that host. + */ + assertMutationSupported: (options: { pinConnection: boolean }) => Promise callRuntimeResult: (method: string, params: unknown) => Promise getSshState: (targetId: string) => Promise } @@ -64,6 +81,88 @@ function assertSameWorktree( } } +function parseFileSnapshot(value: unknown): RuntimeFileSnapshot { + if ( + typeof value !== 'object' || + value === null || + !('size' in value && typeof value.size === 'number') || + !('isDirectory' in value && typeof value.isDirectory === 'boolean') || + !('mtime' in value && typeof value.mtime === 'number') + ) { + throw new Error('Remote file stat returned an invalid result') + } + const ctime = 'ctime' in value && typeof value.ctime === 'number' ? value.ctime : undefined + return { size: value.size, isDirectory: value.isDirectory, mtime: value.mtime, ctime } +} + +function parseFileChunk(value: unknown): RuntimeFileReadChunkResult { + if ( + typeof value !== 'object' || + value === null || + !('contentBase64' in value && typeof value.contentBase64 === 'string') || + !('bytesRead' in value && typeof value.bytesRead === 'number') || + !('eof' in value && typeof value.eof === 'boolean') + ) { + throw new Error('Remote file read returned an invalid chunk') + } + return { contentBase64: value.contentBase64, bytesRead: value.bytesRead, eof: value.eof } +} + +/** A file past one reply's size cap, read in chunks the way the desktop client pages it. */ +async function readWholeTextFile( + session: WebFileMutationSession, + file: ResolvedWebRuntimeFile +): Promise { + const address = { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + } + const paged = await readFileInChunks({ + stat: async () => parseFileSnapshot(await session.callRuntimeResult('files.stat', address)), + readChunk: async (offset, length) => + parseFileChunk( + await session.callRuntimeResult('files.readChunk', { ...address, offset, length }) + ) + }) + if (paged.kind !== 'ok') { + return undefined + } + const text = pagedTextFileResult(paged.bytes) + return text.isBinary ? undefined : text.content +} + +/** Paired servers may predate the precondition; read through the same preview the editor loaded from, then compare. */ +async function checkWebDiskPreconditionFromClient( + session: WebFileMutationSession, + file: ResolvedWebRuntimeFile, + precondition: FileWriteDiskPrecondition +): Promise { + let preview: unknown + try { + preview = await session.callRuntimeResult('files.readPreview', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + } catch (error) { + // Why: the preview stops at one reply; a file the editor grew past that by saving is paged. + if (error instanceof Error && error.message === 'file_too_large') { + // Why undefined on failure: a host that can't page it keeps the earlier answer, 'changed'. + const text = await readWholeTextFile(session, file).catch(() => undefined) + return compareDiskWritePrecondition(precondition, text) + } + return diskConflictFromReadFailure(precondition, error) + } + const text = + typeof preview === 'object' && + preview !== null && + !('isBinary' in preview && preview.isBinary === true) && + 'content' in preview && + typeof preview.content === 'string' + ? preview.content + : undefined + return compareDiskWritePrecondition(precondition, text) +} + export function createWebFileMutationMethods( dependencies: WebFileMutationDependencies ): WebFileMutationMethod { @@ -71,22 +170,57 @@ export function createWebFileMutationMethods( session: WebFileMutationSession, method: string, file: ResolvedWebRuntimeFile, - params: Record - ): Promise => { - await session.assertMutationSupported() + params: Record, + // Why: lets a capability-gated request branch on the same status the support check fetched. + refine?: ( + capabilities: readonly string[] + ) => Promise<{ method: string; params: Record } | FileWriteResult>, + pinConnection = false + ): Promise => { + const capabilities = await session.assertMutationSupported({ pinConnection }) const provenance = await captureWebFileMutationProvenance(file, session.getSshState) - await session.callRuntimeResult(method, { + const refined = refine ? await refine(capabilities) : { method, params } + if ('ok' in refined) { + return refined + } + return session.callRuntimeResult(refined.method, { worktree: toRuntimeWorktreeSelector(file.worktree.id), - ...params, + ...refined.params, ...provenance }) } return { - writeFile: async ({ filePath, content }) => { + writeFile: async ({ filePath, content, diskPrecondition }) => { const session = dependencies.captureSession() const file = await session.resolveFilePath(filePath) - await callMutation(session, 'files.write', file, { relativePath: file.relativePath, content }) + const params = { relativePath: file.relativePath, content } + return parseFileWriteResult( + await callMutation( + session, + 'files.write', + file, + params, + async (capabilities) => { + if (!diskPrecondition) { + return { method: 'files.write', params } + } + if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) { + return { + method: FILE_WRITE_IF_UNCHANGED_METHOD, + params: { ...params, diskPrecondition } + } + } + const diskConflict = await checkWebDiskPreconditionFromClient( + session, + file, + diskPrecondition + ) + return diskConflict ? { ok: false, diskConflict } : { method: 'files.write', params } + }, + diskPrecondition !== undefined + ) + ) }, createFile: async ({ filePath }) => { const session = dependencies.captureSession() diff --git a/src/renderer/src/web/web-file-write-disk-precondition.test.ts b/src/renderer/src/web/web-file-write-disk-precondition.test.ts new file mode 100644 index 000000000000..7ab5634385f4 --- /dev/null +++ b/src/renderer/src/web/web-file-write-disk-precondition.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' +import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from '../../../shared/file-write-disk-precondition' +import { createWebFileMutationMethods } from './web-file-mutation-methods' + +const precondition = { expectedSignature: getDiskBaselineSignature('original') } + +function methodsFor( + capabilities: readonly string[], + diskContent: string | null, + readError = "ENOENT: no such file or directory, stat '/hub/repo/readme.md'" +) { + const callRuntimeResult = vi.fn(async (method: string, _params: unknown): Promise => { + if (method === 'files.readPreview') { + if (diskContent === null) { + throw new Error(readError) + } + return { content: diskContent, isBinary: false } + } + return { ok: true } + }) + const methods = createWebFileMutationMethods({ + captureSession: () => ({ + assertMutationSupported: async () => capabilities, + callRuntimeResult, + getSshState: async () => null, + resolveFilePath: async () => ({ + worktree: { id: 'wt-local', hostId: 'local' }, + relativePath: 'readme.md' + }) + }) + }) + const calledMethods = () => callRuntimeResult.mock.calls.map(([method]) => method) + return { methods, callRuntimeResult, calledMethods } +} + +describe('paired web client save precondition', () => { + it('lets a server that enforces the precondition compare at write time', async () => { + const { methods, callRuntimeResult, calledMethods } = methodsFor( + [FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY], + 'original' + ) + + await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: precondition + }) + + expect(calledMethods()).toEqual(['files.writeIfUnchanged']) + expect(callRuntimeResult.mock.calls[0]?.[1]).toMatchObject({ diskPrecondition: precondition }) + }) + + it('refuses without writing when an older server holds newer disk content', async () => { + const { methods, calledMethods } = methodsFor([], 'agent rewrite') + + const result = await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: precondition + }) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(calledMethods()).toEqual(['files.readPreview']) + }) + + it('writes through an older server without the field once the disk matches', async () => { + const { methods, callRuntimeResult, calledMethods } = methodsFor([], 'original') + + const result = await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: precondition + }) + + expect(result).toEqual({ ok: true }) + expect(calledMethods()).toEqual(['files.readPreview', 'files.write']) + expect(callRuntimeResult.mock.calls[1]?.[1]).not.toHaveProperty('diskPrecondition') + }) + + it('reports a confirmed-missing file to an autosave through an older server', async () => { + const { methods, calledMethods } = methodsFor([], null) + + const result = await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: precondition + }) + + expect(result).toEqual({ ok: false, diskConflict: 'missing' }) + expect(calledMethods()).toEqual(['files.readPreview']) + }) + + it('fails an explicit save, without writing, when an older server read fails otherwise', async () => { + const { methods, calledMethods } = methodsFor([], null, 'Runtime request timed out') + + await expect( + methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: { ...precondition, allowMissing: true } + }) + ).rejects.toThrow('timed out') + expect(calledMethods()).toEqual(['files.readPreview']) + }) + + it('reports a too-large file an older server also cannot page as changed', async () => { + // The paired client surfaces a failed envelope as an Error carrying the wire message. + const { methods, calledMethods } = methodsFor([], null, 'file_too_large') + + const result = await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: { ...precondition, allowMissing: true } + }) + + expect(result).toEqual({ ok: false, diskConflict: 'changed' }) + expect(calledMethods()).not.toContain('files.write') + }) + + it.each([ + ['writes when the paged disk still matches', 'x'.repeat(4_000_000), { ok: true }], + [ + 'refuses when the paged disk differs', + 'y'.repeat(4_000_000), + { ok: false, diskConflict: 'changed' } + ] + ])( + 'pages a file past the preview cap through an older server: %s', + async (_label, disk, expected) => { + // The editor saved this file past one reply's size; the next save's compare must still read it. + const { methods, callRuntimeResult, calledMethods } = methodsFor([], null, 'file_too_large') + const bytes = Buffer.from(disk, 'utf8') + callRuntimeResult.mockImplementation(async (method: string, params: unknown) => { + if (method === 'files.readPreview') { + throw new Error('file_too_large') + } + if (method === 'files.stat') { + return { size: bytes.length, isDirectory: false, mtime: 1, ctime: 1 } + } + if (method === 'files.readChunk' && typeof params === 'object' && params !== null) { + const offset = 'offset' in params && typeof params.offset === 'number' ? params.offset : 0 + const length = 'length' in params && typeof params.length === 'number' ? params.length : 0 + const chunk = bytes.subarray(offset, offset + length) + return { + contentBase64: chunk.toString('base64'), + bytesRead: chunk.length, + eof: offset + chunk.length >= bytes.length + } + } + return { ok: true } + }) + + const result = await methods.writeFile({ + filePath: '/hub/repo/readme.md', + content: 'edited', + diskPrecondition: { expectedSignature: getDiskBaselineSignature('x'.repeat(4_000_000)) } + }) + + expect(result).toEqual(expected) + expect(calledMethods().includes('files.write')).toBe(expected.ok) + } + ) +}) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 7916a15ea8d9..b1be52e77192 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -98,10 +98,14 @@ export class WebRuntimeClient { } } + get connectionEpoch(): number { + return this.transport.requestRegistry.connectionEpoch + } + call( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; expectedConnectionEpoch?: number } ): Promise> { return method === 'status.get' && this.statusOwner ? this.statusOwner.refresh(options) diff --git a/src/renderer/src/web/web-runtime-connection-transport.ts b/src/renderer/src/web/web-runtime-connection-transport.ts index 8a2252432bb7..d5383c87785f 100644 --- a/src/renderer/src/web/web-runtime-connection-transport.ts +++ b/src/renderer/src/web/web-runtime-connection-transport.ts @@ -1,5 +1,4 @@ import type { PairedRuntimeConnection } from '../../../shared/paired-runtime-connection' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { withReconnectJitter } from '../../../shared/reconnect-jitter' import { WebRuntimeConnectionHeartbeat } from './web-runtime-connection-heartbeat' import { @@ -39,7 +38,7 @@ export class WebRuntimeConnectionTransport { private intentionallyClosed = false private readonly timers = new WebRuntimeConnectionTimers() private readonly subscriptionRegistry: WebRuntimeSubscriptionRegistry - private readonly requestRegistry: WebRuntimeRequestRegistry + readonly requestRegistry: WebRuntimeRequestRegistry private readonly connectionWaiters: WebRuntimeConnectionWaiters constructor( @@ -85,13 +84,8 @@ export class WebRuntimeConnectionTransport { this.openConnection() } - async call( - method: string, - params?: unknown, - options?: { timeoutMs?: number; signal?: AbortSignal } - ): Promise> { - return this.requestRegistry.call(method, params, options) - } + readonly call: WebRuntimeRequestRegistry['call'] = (method, params, options) => + this.requestRegistry.call(method, params, options) close(options: { notifySubscriptions?: boolean } = {}): void { this.intentionallyClosed = true @@ -182,6 +176,7 @@ export class WebRuntimeConnectionTransport { setState(next: WebRuntimeConnectionState): void { this.state = next if (next === 'connected') { + this.requestRegistry.connectionOpened() this.subscriptionRegistry.replayInterrupted() this.heartbeat.start() this.connectionWaiters.resolveAll() diff --git a/src/renderer/src/web/web-runtime-request-registry-connection-pin.test.ts b/src/renderer/src/web/web-runtime-request-registry-connection-pin.test.ts new file mode 100644 index 000000000000..c1ea3bcf3e5c --- /dev/null +++ b/src/renderer/src/web/web-runtime-request-registry-connection-pin.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + RUNTIME_CONNECTION_CHANGED_MESSAGE, + WebRuntimeRequestRegistry +} from './web-runtime-request-registry' + +describe('web runtime request connection pin', () => { + beforeEach(() => { + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis) + }) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('does not send a pinned request after the same client silently reconnected', async () => { + let reconnect: () => void = () => undefined + const sendEncrypted = vi.fn(() => true) + const registry: WebRuntimeRequestRegistry = new WebRuntimeRequestRegistry({ + deviceToken: 'token', + nextId: () => 'req-1', + // The socket drops after the probe; the call waits and resumes on the new connection. + waitForConnected: () => + new Promise((resolve) => { + reconnect = () => { + registry.connectionOpened() + resolve() + } + }), + sendEncrypted + }) + registry.connectionOpened() + const probedEpoch = registry.connectionEpoch + + const write = registry.call('files.write', {}, { expectedConnectionEpoch: probedEpoch }) + reconnect() + + await expect(write).rejects.toThrow(RUNTIME_CONNECTION_CHANGED_MESSAGE) + expect(sendEncrypted).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/web-runtime-request-registry.ts b/src/renderer/src/web/web-runtime-request-registry.ts index f3cd95315021..26ad5ccff3d8 100644 --- a/src/renderer/src/web/web-runtime-request-registry.ts +++ b/src/renderer/src/web/web-runtime-request-registry.ts @@ -10,8 +10,12 @@ type WebRuntimeRequestRegistryOptions = { sendEncrypted: (message: unknown) => boolean } +export const RUNTIME_CONNECTION_CHANGED_MESSAGE = 'Runtime connection changed; try again.' + export class WebRuntimeRequestRegistry { readonly pending = new Map() + /** Bumped per established connection, so a caller can pin a request to the one it probed. */ + connectionEpoch = 0 constructor(private readonly options: WebRuntimeRequestRegistryOptions) {} @@ -34,14 +38,25 @@ export class WebRuntimeRequestRegistry { }) } + connectionOpened(): void { + this.connectionEpoch += 1 + } + async call( method: string, params?: unknown, - callOptions?: { timeoutMs?: number; signal?: AbortSignal } + callOptions?: { timeoutMs?: number; signal?: AbortSignal; expectedConnectionEpoch?: number } ): Promise> { const signal = callOptions?.signal await this.options.waitForConnected(callOptions?.timeoutMs, signal) signal?.throwIfAborted() + // Why: checked once the connection is ready, right before sending โ€” where a silent reconnect lands. + if ( + callOptions?.expectedConnectionEpoch !== undefined && + callOptions.expectedConnectionEpoch !== this.connectionEpoch + ) { + throw new Error(RUNTIME_CONNECTION_CHANGED_MESSAGE) + } return new Promise((resolve, reject) => { const id = this.options.nextId() const timeoutMs = callOptions?.timeoutMs ?? REQUEST_TIMEOUT_MS diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index ec5542058884..e8f19b0f55be 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -11,7 +11,6 @@ import { } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' import { PANE_KEY } from './agent-hook-listener-test-harness' @@ -509,7 +508,7 @@ describe('shared agent-hook-listener', () => { ]) }) - it('keeps a live child omitted by the background task snapshot cap', () => { + it('keeps a live child omitted by an inventory with an unreadable entry', () => { claudeEvent({ hook_event_name: 'SubagentStart', agent_id: 'alive-after-cap', @@ -517,15 +516,14 @@ describe('shared agent-hook-listener', () => { }) const stop = claudeEvent({ hook_event_name: 'Stop', - background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ - id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, - type: 'subagent', - status: 'running' - })) + background_tasks: [ + { id: 'a0', type: 'subagent', status: 'running' }, + { id: 'unreadable', status: 'running' } + ] }) - // Why: the inventory was capped before this id, so omission cannot - // prove the lifecycle-tracked child finished or was killed. + // Why: an unreadable entry may be this id, so omission cannot prove + // the lifecycle-tracked child finished or was killed. expect(stop?.payload.subagents).toContainEqual( expect.objectContaining({ id: 'alive-after-cap', state: 'working' }) ) diff --git a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts index 4046eb3e6457..70678dae1b69 100644 --- a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts +++ b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts @@ -5,7 +5,6 @@ import { } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' import { seedClaudeSubagentRosterFromSnapshots } from './agent-hook-listener/providers/claude-roster-state' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -286,11 +285,10 @@ describe('Claude child lifecycle events with no cached lead state', () => { const stopped = claudeEvent(state, paneKey, { hook_event_name: 'Stop', - background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ - id: `finished-${index}`, - type: 'subagent', - status: 'completed' - })) + background_tasks: [ + { id: 'finished-0', type: 'subagent', status: 'completed' }, + { id: 'unreadable', status: 'completed' } + ] }) expect(stopped).toMatchObject({ diff --git a/src/shared/agent-hook-listener/providers/muse-events.ts b/src/shared/agent-hook-listener/providers/muse-events.ts index 7c4889bb14a5..92b136090b46 100644 --- a/src/shared/agent-hook-listener/providers/muse-events.ts +++ b/src/shared/agent-hook-listener/providers/muse-events.ts @@ -13,8 +13,6 @@ import { import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' -const MAX_TRACKED_CHILD_SESSIONS = 64 - function getMusePaneState(state: HookListenerState, paneKey: string): MusePaneState { let pane = state.musePaneStateByPaneKey.get(paneKey) if (!pane) { @@ -24,16 +22,6 @@ function getMusePaneState(state: HookListenerState, paneKey: string): MusePaneSt return pane } -function rememberChildSession(pane: MusePaneState, childSessionId: string): void { - pane.childSessionIds.add(childSessionId) - if (pane.childSessionIds.size > MAX_TRACKED_CHILD_SESSIONS) { - const oldest = pane.childSessionIds.values().next().value - if (oldest !== undefined) { - pane.childSessionIds.delete(oldest) - } - } -} - function isChildSessionEvent(pane: MusePaneState, hookPayload: Record): boolean { const sessionId = readString(hookPayload, 'session_id') if (!sessionId) { @@ -66,7 +54,7 @@ export function normalizeMuseEvent( const childSessionId = readString(hookPayload, 'child_session_id') ?? readString(hookPayload, 'session_id') if (childSessionId) { - rememberChildSession(pane, childSessionId) + pane.childSessionIds.add(childSessionId) } return null } diff --git a/src/shared/agent-launch-model-preferences.ts b/src/shared/agent-launch-model-preferences.ts index aea474cdb5ac..246bc730cad8 100644 --- a/src/shared/agent-launch-model-preferences.ts +++ b/src/shared/agent-launch-model-preferences.ts @@ -11,6 +11,7 @@ import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' import type { TuiAgent } from './tui-agent' +import { isOmpThinkingLevel } from './omp-model-list-probe' export type AgentLaunchModelPreferenceRefusal = { code: 'invalid_argument' | 'capability_unsupported' @@ -56,6 +57,9 @@ export function checkAgentLaunchModelPreferences(args: { ) } + if (args.agent === 'omp' && args.effort && !isOmpThinkingLevel(args.effort)) { + return refuse('invalid_argument', `OMP does not support thinking ${args.effort}.`) + } if (args.effort) { const model = findCatalogModel(catalog, args.model) const option = diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 3f3133c82288..f538b84aeeda 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -49,6 +49,9 @@ export const AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY = export const AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY = 'agent.launch.create-folder-workspace.v1' as const +// Host-advertised: OMP launch preferences preserve native thinking, including off and auto. +export const AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY = 'agent.launch.omp-thinking.v1' as const + // Client-advertised only: the client reads a listed launch tab with no terminal yet as "not // started", never as proof its agent runs. The host publishes a paired caller's tab before the // spawn only for a client that says so. @@ -70,5 +73,6 @@ export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY, AGENT_LAUNCH_LOCAL_PRESENTATION_RUNTIME_CAPABILITY, - AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY + AGENT_LAUNCH_CREATE_FOLDER_WORKSPACE_RUNTIME_CAPABILITY, + AGENT_LAUNCH_OMP_THINKING_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-session-agent-availability.ts b/src/shared/agent-session-agent-availability.ts new file mode 100644 index 000000000000..79f88d7584b1 --- /dev/null +++ b/src/shared/agent-session-agent-availability.ts @@ -0,0 +1,58 @@ +/** + * `agentSession.subscribeAvailability`: the health of this host's installed agents, from each + * agent's location rule and installed-release check on the host itself, not in any workspace. + * Every frame is the complete snapshot and replaces the one before. Published only by hosts + * advertising the availability capability; `agentSession.agents` stays the static list. + */ + +import { z } from 'zod' +import { isStructuredAgentId } from './agent-session-provider-handle-encoding' +import { openEnum } from './zod-salvage' + +export const AGENT_SESSION_AVAILABILITY_METHOD = 'agentSession.subscribeAvailability' + +/** `refused` carries the reason (`agent`: this host cannot run its chat); `unknown` is a check that + * could not answer, which is never a refusal. */ +export type AgentSessionAgentAvailability = + | { agent: string; state: 'available' } + | { agent: string; state: 'refused'; reason?: string } + | { agent: string; state: 'unknown' } + +export type AgentSessionAvailabilityFrame = { + type: 'availability' + agents: AgentSessionAgentAvailability[] +} + +const MAX_AGENTS = 128 + +const rowSchema = z.object({ + agent: z.string().refine(isStructuredAgentId), + // A state this build does not know claims nothing. + state: openEnum(['available', 'refused', 'unknown'] as const, 'unknown'), + reason: z.string().max(64).optional() +}) + +/** One frame's answers by agent, or null when the frame is not an availability frame. */ +export function decodeAgentSessionAvailabilityFrame( + value: unknown +): ReadonlyMap | null { + if (typeof value !== 'object' || value === null || !('type' in value) || !('agents' in value)) { + return null + } + if (value.type !== 'availability' || !Array.isArray(value.agents)) { + return null + } + const byAgent = new Map() + for (const row of value.agents.slice(0, MAX_AGENTS)) { + const parsed = rowSchema.safeParse(row) + if (!parsed.success || byAgent.has(parsed.data.agent)) { + continue + } + const { agent, state, reason } = parsed.data + byAgent.set( + agent, + state === 'refused' ? { agent, state, ...(reason ? { reason } : {}) } : { agent, state } + ) + } + return byAgent +} diff --git a/src/shared/agent-session-background-task-wire.ts b/src/shared/agent-session-background-task-wire.ts index 100c674544d2..c6db42ee9177 100644 --- a/src/shared/agent-session-background-task-wire.ts +++ b/src/shared/agent-session-background-task-wire.ts @@ -55,10 +55,10 @@ export type AgentSessionBackgroundTaskState = { * advertise `agent-session.background-task-child-views.v1` never receives a state whose only * rows are settled: to it, as before, the roster is live work. */ children?: AgentChildWorkView[] - /** The host refused some of this session's child work for room, so what is listed is not all - * that runs; no row's absence means it ended. A refused child stops counting once a later - * report of it is admitted or it reports its own end; past the host's bound, and in every case - * once the provider session ends, the whole flag goes. */ + /** Some of this session's running child work has no row (its record was refused, has not landed + * yet, or a bounded frame dropped it), so what is listed is not all that runs; no row's absence + * means it ended. A missing child stops counting once a later report of it is admitted or it + * reports its own end, and the whole flag goes once the provider session ends. */ incomplete?: true } diff --git a/src/shared/agent-session-option-catalog-omp.test.ts b/src/shared/agent-session-option-catalog-omp.test.ts index d874a1bd0c69..9a3afaf3a065 100644 --- a/src/shared/agent-session-option-catalog-omp.test.ts +++ b/src/shared/agent-session-option-catalog-omp.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' -import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' import { parseBuiltSessionOptionCommand } from './native-chat-session-option-commands' -import { OMP_MODEL_LIST_ARGS, parseOmpModelList } from './omp-model-list-probe' +import { parseOmpModelList } from './omp-model-list-probe' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' const LISTING = JSON.stringify({ models: [ @@ -31,12 +38,10 @@ const LISTING = JSON.stringify({ }) describe('omp model list probe', () => { - it('asks omp for its machine-readable listing', () => { - expect(OMP_MODEL_LIST_ARGS).toEqual(['models', '--json']) - }) - it('parses selectors as ids, names as labels, and providers as descriptions', () => { - expect(parseOmpModelList(LISTING)).toEqual([ + expect( + parseOmpModelList(LISTING).map(({ thinkingLevels: _thinking, ...model }) => model) + ).toEqual([ { id: 'deepseek/deepseek-v4-pro', label: 'DeepSeek V4 Pro', description: 'deepseek' }, { id: 'minimax-cn/MiniMax-M3', label: 'MiniMax M3', description: 'minimax-cn' }, { id: 'openai/gpt-5.5', label: 'Openai GPT 5.5', description: 'openai' } @@ -109,29 +114,135 @@ describe('omp model list probe', () => { }) describe('omp session option catalog', () => { - it('is registered for the omp agent', () => { - expect(getAgentSessionOptionCatalog('omp')).toBe(OMP_SESSION_OPTION_CATALOG) + it('does not invent OMP thinking choices for a reported model with no advertised metadata', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('omp'), + { ...OMP_SESSION_OPTION_CATALOG }, + { models: [], current: { model: 'opaque-model', effort: 'high' } } + ) + expect(state.catalog?.models).toEqual([ + { id: 'opaque-model', label: 'opaque-model', options: [] } + ]) + expect( + structuredAgentSessionOptionSnapshot(state).some((descriptor) => descriptor.id === 'effort') + ).toBe(false) + }) + + it('keeps model-specific native thinking choices without guessing a default', () => { + const models = parseOmpModelList(LISTING) + expect(models[0].thinkingLevels?.map(({ id }) => id)).toEqual([ + 'off', + 'low', + 'high', + 'max', + 'auto' + ]) + expect(models[0].defaultThinkingLevel).toBeUndefined() + expect(models[1].thinkingLevels).toBeUndefined() + expect(models[2].thinkingLevels).toBeUndefined() + const options = OMP_SESSION_OPTION_CATALOG.listModels!.parse(LISTING)[0].options + expect(options[0].kind).toMatchObject({ defaultValue: 'config-default' }) + expect(options[0].apply.midSession).toEqual({ kind: 'unsupported' }) }) - it('seeds no model because none is available on every install', () => { - expect(OMP_SESSION_OPTION_CATALOG.models).toEqual([]) - expect(OMP_SESSION_OPTION_CATALOG.defaultModelIsCliDefault).toBeUndefined() - expect(OMP_SESSION_OPTION_CATALOG.discoveredModelsAreAuthoritative).toBe(true) + it('drops malformed, duplicate, and foreign thinking values', () => { + const model = parseOmpModelList( + JSON.stringify({ + models: [ + { + provider: 'custom', + id: 'model', + thinking: ['high', 'high', 'ultra', 'turbo', null, 12] + } + ] + }) + )[0] + expect(model.thinkingLevels?.map(({ id }) => id)).toEqual(['off', 'high', 'auto']) }) - it('discovers models through the same JSON listing the probe parses', () => { - const listModels = OMP_SESSION_OPTION_CATALOG.listModels! - expect(listModels.command).toBe('omp models --json') - expect(listModels.parse(LISTING)).toEqual([ - { - id: 'deepseek/deepseek-v4-pro', - label: 'DeepSeek V4 Pro', - description: 'deepseek', - options: [] - }, - { id: 'minimax-cn/MiniMax-M3', label: 'MiniMax M3', description: 'minimax-cn', options: [] }, - { id: 'openai/gpt-5.5', label: 'Openai GPT 5.5', description: 'openai', options: [] } - ]) + it.each([ + { thinking: undefined }, + { thinking: null }, + { thinking: [] }, + { thinking: ['off', 'auto'] }, + { thinking: ['ultra'] }, + { thinking: 'high' } + ])('does not invent thinking capabilities from $thinking', ({ thinking }) => { + const listing = JSON.stringify({ models: [{ provider: 'custom', id: 'plain', thinking }] }) + expect(parseOmpModelList(listing)[0].thinkingLevels).toBeUndefined() + expect(OMP_SESSION_OPTION_CATALOG.listModels!.parse(listing)[0].options).toEqual([]) + }) + + it('keeps configured thinking unset and yields to explicit native launch flags', () => { + const model = 'custom/model' + expect( + resolveAgentSessionOptionLaunch('omp', { model, effort: 'config-default' }, [], true) + ).toEqual({ + args: ['--model', model], + appliedValues: { model } + }) + expect( + resolveAgentSessionOptionLaunch( + 'omp', + { model, effort: 'high' }, + ['--model=other/model', '--thinking=auto', '--no-extensions'], + false + ) + ).toEqual({ args: [], appliedValues: {} }) + expect( + removeOverriddenAgentSessionArgs('omp', { model, effort: 'config-default' }, [ + '--thinking=high', + '--no-extensions', + '--', + '--thinking', + 'low' + ]) + ).toEqual(['--thinking=high', '--no-extensions', '--', '--thinking', 'low']) + }) + + it.each([undefined, 'config-default'])( + 'preserves user-authored thinking arguments without an explicit override: %s', + (effort) => { + const tokens = ['--thinking=auto', '--no-extensions', '--', '--thinking', 'low'] + const values = { model: 'provider/model:high', ...(effort ? { effort } : {}) } + expect(removeOverriddenAgentSessionArgs('omp', values, tokens)).toEqual(tokens) + expect(resolveAgentSessionOptionLaunch('omp', values, tokens, false)).toEqual({ + args: ['--model', values.model], + appliedValues: { model: values.model } + }) + } + ) + + it.each(['off', 'auto'])('passes explicit %s unchanged for native validation', (effort) => { + const picks = { model: 'provider/model:high', effort } + const tokens = ['--thinking=low', '--no-extensions'] + const remaining = removeOverriddenAgentSessionArgs('omp', picks, tokens) + expect(remaining).toEqual(['--no-extensions']) + expect(resolveAgentSessionOptionLaunch('omp', picks, remaining, false)).toEqual({ + args: ['--model', picks.model, '--thinking', effort], + appliedValues: picks + }) + }) + + it('launches native thinking and removes conflicting free-form flags for explicit picks', () => { + const picks = { model: 'deepseek/deepseek-v4-pro', effort: 'max' } + expect(resolveAgentSessionOptionLaunch('omp', picks, [], false)).toEqual({ + args: ['--model', picks.model, '--thinking', 'max'], + appliedValues: picks + }) + const args = ['--thinking', 'low', '--thinking=auto', '--no-extensions'] + expect(removeOverriddenAgentSessionArgs('omp', picks, args)).toEqual(['--no-extensions']) + expect(resolveAgentSessionOptionLaunch('omp', picks, args, false)).toEqual({ + args: ['--model', picks.model], + appliedValues: { model: picks.model } + }) + // Launch sees every selector as opaque; only native thinking levels may pass to OMP. + expect( + resolveAgentSessionOptionLaunch('omp', { ...picks, effort: 'ultra' }, [], false) + ).toEqual({ + args: ['--model', picks.model], + appliedValues: { model: picks.model } + }) }) it('launches a picked model with --model ', () => { diff --git a/src/shared/agent-session-option-catalog-omp.ts b/src/shared/agent-session-option-catalog-omp.ts index 2008f909abcb..538725b25154 100644 --- a/src/shared/agent-session-option-catalog-omp.ts +++ b/src/shared/agent-session-option-catalog-omp.ts @@ -1,12 +1,47 @@ import { removeAgentArgOption } from './agent-session-option-agent-args' -import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog-types' -import { parseOmpModelList } from './omp-model-list-probe' +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' +import { OMP_THINKING_LEVELS, parseOmpModelList } from './omp-model-list-probe' + +export const OMP_CONFIG_DEFAULT_THINKING = 'config-default' + +export function createOmpCatalogOptions( + levels: readonly { id: string; label: string }[] +): CatalogOption[] { + if (!levels.length) { + return [] + } + return [ + { + id: 'effort', + label: 'Thinking', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: OMP_CONFIG_DEFAULT_THINKING, label: 'Config default' }, + ...levels.map(({ id, label }) => ({ value: id, label })) + ], + defaultValue: OMP_CONFIG_DEFAULT_THINKING + }, + apply: { + launchArgs: (value) => ['--thinking', String(value)], + removeAgentArgs: (tokens) => removeAgentArgOption('omp', tokens, ['--thinking']), + // The extension API reports effective ThinkingLevel, not the configured auto policy. + midSession: { kind: 'unsupported' } + } + } + ] +} -/** Catalog rows for `omp models --json` output; every OMP model carries no options. - * The enrichment gate reads only `listModels`' presence; the probe itself parses - * through `agent-model-probe-spec.ts`, so keep this private to that gate. */ function parseOmpCatalogModels(stdout: string): CatalogModel[] { - return parseOmpModelList(stdout).map((model) => ({ ...model, options: [] })) + return parseOmpModelList(stdout).map((model) => ({ + ...model, + options: createOmpCatalogOptions(model.thinkingLevels ?? []) + })) } export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { @@ -16,6 +51,10 @@ export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { // one. Seed nothing: desktop fills the picker from discovery, and every surface // keeps the row the hook reports as the session's current model. models: [], + // Opaque worker ids accept native CLI selectors; OMP owns model-specific clamping. + unknownModelOptions: createOmpCatalogOptions( + OMP_THINKING_LEVELS.map((id) => ({ id, label: id })) + ), modelApply: { launchArgs: (value) => ['--model', String(value)], removeAgentArgs: (tokens) => removeAgentArgOption('omp', tokens, ['--model']), @@ -25,5 +64,6 @@ export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { // Why: with no seed there is nothing to keep; a persisted id the host no longer // lists is a fatal `--model` at launch, so discovery must be able to retire it. discoveredModelsAreAuthoritative: true, + liveModelOptionsAreAuthoritative: true, listModels: { command: 'omp models --json', parse: parseOmpCatalogModels } } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index ea631ebc4ee9..652a5ebcb62b 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -68,6 +68,8 @@ export type AgentSessionOptionCatalog = { * must be able to drop it rather than only add. Membership only โ€” option menus * still come from the seed. */ discoveredModelsAreAuthoritative?: true + /** Live model rows own their option menus; opaque worker launch options remain separate. */ + liveModelOptionsAreAuthoritative?: true /** Set only when the `isDefault` model is provably what the CLI runs with no model * flag, so an untouched draft may show it as selected. Off means `isDefault` stays * decorative: agents whose default comes from account or user config would otherwise diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index e8c972385bdc..4f0ddfb9fdc0 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,6 @@ import type { AgentType } from './agent-status-types' import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { OMP_CONFIG_DEFAULT_THINKING } from './agent-session-option-catalog-omp' import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode' import type { AgentSessionOptionCatalog, @@ -37,6 +38,13 @@ export function removeOverriddenAgentSessionArgs( const model = findCatalogModel(catalog, modelId) const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] for (const option of modelOptions) { + if ( + agent === 'omp' && + option.id === 'effort' && + values[option.id] === OMP_CONFIG_DEFAULT_THINKING + ) { + continue + } if (values[option.id] !== undefined && option.apply.removeAgentArgs) { result = option.apply.removeAgentArgs(result) } @@ -63,6 +71,13 @@ export function resolveAgentSessionOptionLaunch( const modelValues = Object.fromEntries( modelOptions.flatMap((option) => { const explicitValue = values[option.id] + if ( + agent === 'omp' && + option.id === 'effort' && + explicitValue === OMP_CONFIG_DEFAULT_THINKING + ) { + return [] + } if (explicitValue !== undefined) { if ( !model && diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..ac275d90c069 --- /dev/null +++ b/src/shared/agent-session-workspace-sleep.ts @@ -0,0 +1,7 @@ +export type AgentSessionWorkspaceSleepResult = { + stoppedSessionIds: string[] + failures?: { + sessionId?: string + reason: 'inventory-unavailable' | 'stop-unverifiable' + }[] +} diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index c3a491360148..ba7ad1e3746f 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -16,16 +16,12 @@ import { agentChildWorkHandleAliases, currentAgentChildWorkAliases, isPreviousAgentChildWorkRun, - ownedStructuredChildWork, resolveAgentChildWorkHandle, resolveAgentChildWorkOwner, STRUCTURED_CHILD_WORK_PROVENANCE, type AgentChildWorkEvidenceScope } from './agent-status-child-work-evidence-resolution' -/** Live children other than agents admitted per session. A subagent has no such bound: refusing - * one would cost its saved row while it runs, so only the store's global limits bound them. */ -export const STRUCTURED_CHILD_WORK_MAX_LIVE = 256 /** A child's first run, matching the provider roster's first attempt. */ const FIRST_GENERATION = 1 @@ -135,15 +131,6 @@ export function applyAgentChildWorkLive( const request = { parent: ctx.parent, provider: ctx.provider } const aliases = agentChildWorkHandleAliases(handle) if (!existing) { - if ( - child.kind !== 'agent' && - ownedStructuredChildWork(ctx).filter( - (record) => record.membership === 'live' && record.kind !== 'agent' - ).length >= STRUCTURED_CHILD_WORK_MAX_LIVE - ) { - ctx.outcome.rejected.push({ handleId: handle.id, reason: 'ingestion-limit' }) - return - } // Bindings with no record left are retired lifetimes: a forgotten session took its children // with it. The id is free again, under a generation past the retired one. const retired = resolution.highestGeneration diff --git a/src/shared/agent-status-child-work-projection.test.ts b/src/shared/agent-status-child-work-projection.test.ts index e9e3d87e8cf5..6473d729d481 100644 --- a/src/shared/agent-status-child-work-projection.test.ts +++ b/src/shared/agent-status-child-work-projection.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { resolveAgentChildWorkFreshness } from './agent-status-child-work-freshness' import { agentChildWorkProjectionCandidateFromBackgroundTask, @@ -80,17 +79,6 @@ describe('agent child-work legacy projection', () => { ]) }) - it.each([31, 32, 33])('caps after accepting %i valid rows in source order', (count) => { - const interleaved = Array.from({ length: count }, (_, index) => [ - candidate('', { kind: 'agent' }), - candidate(`task-${index}`) - ]).flat() - const projected = projectAgentChildWorkLegacySubagents(interleaved) - - expect(projected).toHaveLength(Math.min(count, AGENT_STATUS_MAX_SUBAGENTS)) - expect(projected?.at(-1)?.id).toBe(`task-${Math.min(count, AGENT_STATUS_MAX_SUBAGENTS) - 1}`) - }) - it('rejects missing host first-observation time instead of inventing zero', () => { expect( projectAgentChildWorkLegacySubagents([ diff --git a/src/shared/agent-status-child-work-projection.ts b/src/shared/agent-status-child-work-projection.ts index 9d4e26230ca5..3ea34ab0c86c 100644 --- a/src/shared/agent-status-child-work-projection.ts +++ b/src/shared/agent-status-child-work-projection.ts @@ -1,5 +1,5 @@ import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' -import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types' +import type { AgentSubagentSnapshot } from './agent-status-types' import { isAgentChildWorkKind } from './agent-status-child-work-liveness' import type { AgentChildWorkOutcome, AgentChildWorkState } from './agent-status-child-work' import type { AgentChildWorkView } from './agent-status-child-work-view' @@ -115,9 +115,6 @@ export function projectAgentChildWorkLegacySubagents( ...(candidate.model !== undefined ? { model: candidate.model } : {}), ...(candidate.description !== undefined ? { description: candidate.description } : {}) }) - if (projected.length === AGENT_STATUS_MAX_SUBAGENTS) { - break - } } return projected.length > 0 ? projected : undefined } diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index e1b2b9772cc7..479c4fd44872 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -6,7 +6,6 @@ import type { AgentChildWorkLiveObservation } from './agent-status-child-work-evidence' import { reconcileAgentChildWorkEvidence } from './agent-status-child-work-reconciliation' -import { STRUCTURED_CHILD_WORK_MAX_LIVE } from './agent-status-child-work-evidence-admission' import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' import { projectAgentChildWorkViews } from './agent-status-child-work-view' import { structuredRunningChildWork } from './agent-child-work-listing' @@ -385,21 +384,6 @@ describe('structured child-work reconciliation', () => { ]) }) - it('bounds the live tasks one session may admit, and never refuses a subagent for it', () => { - const { store, apply } = harness() - const command = (id: string) => child(id, { kind: 'command' }) - apply( - ...Array.from({ length: STRUCTURED_CHILD_WORK_MAX_LIVE }, (_, index) => - live(command(`task-${index}`)) - ) - ) - expect(apply(live(command('one-too-many'))).rejected).toEqual([ - { handleId: 'one-too-many', reason: 'ingestion-limit' } - ]) - expect(apply(live(child('agent-past-the-limit'))).rejected).toEqual([]) - expect(records(store)).toHaveLength(STRUCTURED_CHILD_WORK_MAX_LIVE + 1) - }) - it('keeps every settled child until the parent row goes', () => { const { store, apply } = harness() for (let index = 0; index < 100; index += 1) { diff --git a/src/shared/agent-status-run-alias-index.test.ts b/src/shared/agent-status-run-alias-index.test.ts index 1502d3e3546d..32a2282d125a 100644 --- a/src/shared/agent-status-run-alias-index.test.ts +++ b/src/shared/agent-status-run-alias-index.test.ts @@ -9,7 +9,6 @@ import { type AgentStatusRunAliasIndex } from './agent-status-run-alias-index' import type { AgentStatusExecutionScope } from './agent-status-subject' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' function scope(overrides: Partial = {}): AgentStatusExecutionScope { return { @@ -74,11 +73,9 @@ describe('agent status provider alias index', () => { expect(decoded?.get(aliasKey)).toEqual(new Set(['run-a', 'run-b'])) }) - it('round-trips every run allowed by the canonical parent-store limit for one alias', () => { + it('round-trips more than 2,048 run ids for one alias', () => { const aliasKey = serializeAgentStatusProviderAliasKey(alias()) - const runIds = new Set( - Array.from({ length: AGENT_STATUS_STORE_LIMITS.parents }, (_, index) => `run-${index}`) - ) + const runIds = new Set(Array.from({ length: 2_049 }, (_, index) => `run-${index}`)) const decoded = deserializeAgentStatusRunAliasIndex( serializeAgentStatusRunAliasIndex(new Map([[aliasKey, runIds]])) diff --git a/src/shared/agent-status-run-alias-index.ts b/src/shared/agent-status-run-alias-index.ts index 80b917fd08f9..2e012072d5e5 100644 --- a/src/shared/agent-status-run-alias-index.ts +++ b/src/shared/agent-status-run-alias-index.ts @@ -8,13 +8,11 @@ import { parseAgentStatusExecutionScope, type AgentStatusExecutionScope } from './agent-status-subject' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { measureUtf8ByteLength } from './utf8-byte-limits' const PROVIDER_ALIAS_KEY_PREFIX = 'agent-status-provider-alias-v1:' const MAX_ALIAS_INDEX_ENTRIES = 4096 -const MAX_RUN_IDS_PER_ALIAS = AGENT_STATUS_STORE_LIMITS.parents const MAX_ALIAS_INDEX_RUN_REFERENCES = 16_384 const MAX_ALIAS_INDEX_SERIALIZED_BYTES = 4 * 1024 * 1024 const ALIAS_INDEX_JSON_STRUCTURE_LIMITS = { @@ -150,11 +148,7 @@ export function serializeAgentStatusRunAliasIndex( const entries: SerializedAliasIndexEntry[] = [] let runReferenceCount = 0 for (const [aliasKey, runIds] of index) { - if ( - !deserializeAgentStatusProviderAliasKey(aliasKey) || - runIds.size === 0 || - runIds.size > MAX_RUN_IDS_PER_ALIAS - ) { + if (!deserializeAgentStatusProviderAliasKey(aliasKey) || runIds.size === 0) { throw new Error('Invalid agent status alias index entry') } const serializedRunIds = [...runIds] @@ -207,7 +201,6 @@ export function deserializeAgentStatusRunAliasIndex( !deserializeAgentStatusProviderAliasKey(entry.alias) || !Array.isArray(entry.runIds) || entry.runIds.length === 0 || - entry.runIds.length > MAX_RUN_IDS_PER_ALIAS || !entry.runIds.every(isAgentStatusRunId) || new Set(entry.runIds).size !== entry.runIds.length || index.has(entry.alias) diff --git a/src/shared/agent-status-store-bounds.test.ts b/src/shared/agent-status-store-bounds.test.ts index 454d5ff843d5..1be59503d90a 100644 --- a/src/shared/agent-status-store-bounds.test.ts +++ b/src/shared/agent-status-store-bounds.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentChildWorkAliasInput } from './agent-status-child-work-alias' import type { AgentChildWorkInput } from './agent-status-child-work' import { serializeAgentStatusRunAliasIndex } from './agent-status-run-alias-index' import { createAgentStatusStore } from './agent-status-store' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' import { commitAgentStatusStoreMutation } from './agent-status-store-commit' import { indexAgentStatusStoreState } from './agent-status-store-indexes' import { agentStatusStoreStateFromSnapshot } from './agent-status-store-state' @@ -24,7 +23,7 @@ const scope: AgentStatusExecutionScope = { function child( parent: ReturnType, childIndex: number, - description = 'x'.repeat(8_000) + description: string ) { return { childWorkId: `child-${childIndex}`, @@ -42,23 +41,7 @@ function child( } satisfies AgentChildWorkInput } -function children(parent: ReturnType, offset: number) { - return Array.from({ length: AGENT_STATUS_STORE_LIMITS.mutationEntries / 2 }, (_, index) => - child(parent, offset + index) - ) -} - describe('AgentStatusStore bounds', () => { - it('rejects a mutation that would make the aggregate snapshot exceed its byte budget', () => { - const parent = makeStructuredAgentStatusSubject(scope, 'session-1') - const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull() - expect(store.applyMutation({ children: children(parent, 0) })).not.toBeNull() - - expect(store.applyMutation({ children: children(parent, 1_024) })).toBeNull() - expect(store.getSnapshot().children).toHaveLength(1_024) - }) - it('serializes a derived provider-alias Set above the former 256-run ceiling', () => { const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) for (let index = 0; index < 257; index += 1) { @@ -90,29 +73,6 @@ describe('AgentStatusStore bounds', () => { expect(() => serializeAgentStatusRunAliasIndex(store.getRunAliasIndex())).not.toThrow() }) - it('reuses immutable record byte measurements when one child changes', () => { - const parent = makeStructuredAgentStatusSubject(scope, 'session-1') - const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - const first = child(parent, 0, 'first-description') - const unchanged = child(parent, 1, 'unchanged-description') - expect( - store.applyMutation({ parent: { subject: parent }, children: [first, unchanged] }) - ).not.toBeNull() - const stringify = vi.spyOn(JSON, 'stringify') - - try { - expect( - store.applyMutation({ children: [{ ...first, observedAt: first.observedAt + 1 }] }) - ).not.toBeNull() - const serializedValues = stringify.mock.results - .map((result) => result.value) - .filter((value): value is string => typeof value === 'string') - expect(serializedValues.some((value) => value.includes('unchanged-description'))).toBe(false) - } finally { - stringify.mockRestore() - } - }) - it("removes a child batch's aliases without reading any other child's aliases", () => { const parent = makeStructuredAgentStatusSubject(scope, 'session-1') const bystander = makeStructuredAgentStatusSubject(scope, 'session-2') diff --git a/src/shared/agent-status-store-byte-budget.test.ts b/src/shared/agent-status-store-byte-budget.test.ts deleted file mode 100644 index ef49288d7616..000000000000 --- a/src/shared/agent-status-store-byte-budget.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, it } from 'vitest' -import { createAgentStatusStore } from './agent-status-store' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' -import { - deserializeAgentStatusStoreSnapshot, - serializeAgentStatusStoreSnapshot -} from './agent-status-store-persistence' -import { makeStructuredAgentStatusSubject } from './agent-status-subject' - -it('rejects cumulative snapshot overflow atomically even when each mutation fits', () => { - const subject = makeStructuredAgentStatusSubject( - { executionHostId: 'local', wslDistro: null, workspaceId: 'folder-a', workspaceKind: 'folder' }, - 'session_11111111-1111-4111-8111-111111111111' - ) - const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - expect(store.applyMutation({ parent: { subject } })).not.toBeNull() - const batch = (offset: number) => ({ - facts: Array.from({ length: 1024 }, (_, index) => ({ - subject, - key: `fact-${offset + index}`, - value: 'x'.repeat(4096) - })) - }) - for (const offset of [0, 1024, 2048]) { - expect(store.applyMutation(batch(offset))).not.toBeNull() - } - const before = store.getSnapshot() - expect(store.applyMutation(batch(3072))).toBeNull() - expect(store.getSnapshot()).toEqual(before) - expect(serializeAgentStatusStoreSnapshot(before).length).toBeLessThan( - AGENT_STATUS_STORE_LIMITS.serializedBytes - ) -}) - -it('can deserialize a dense valid snapshot within the declared record and byte budgets', () => { - const subject = makeStructuredAgentStatusSubject( - { executionHostId: 'local', wslDistro: null, workspaceId: 'folder-a', workspaceKind: 'folder' }, - 'session_11111111-1111-4111-8111-111111111111' - ) - const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - expect( - store.applySnapshot({ - version: 1, - epoch: 'persisted', - revision: 1, - parents: [{ subject, revision: 1 }], - children: Array.from({ length: 8192 }, (_, index) => ({ - childWorkId: `child-${index}`, - parent: subject, - provider: 'claude', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 10, - observedAt: 10, - stoppable: false, - invocation: { invocationId: 'invocation-1', generation: 1 }, - provenance: { source: 'structured-session', producerId: 'journal' }, - revision: 1 - })), - aliases: [], - facts: Array.from({ length: 8192 }, (_, index) => ({ - subject, - key: `fact-${index}`, - value: true, - revision: 1 - })), - tombstones: [] - }) - ).toBe(true) - const snapshot = store.getSnapshot() - expect(deserializeAgentStatusStoreSnapshot(serializeAgentStatusStoreSnapshot(snapshot))).toEqual( - snapshot - ) -}) diff --git a/src/shared/agent-status-store-byte-budget.ts b/src/shared/agent-status-store-byte-budget.ts deleted file mode 100644 index 1a64866c3e46..000000000000 --- a/src/shared/agent-status-store-byte-budget.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { AgentChildWorkAliasRecord } from './agent-status-child-work-alias' -import type { AgentChildWorkRecord } from './agent-status-child-work' -import { - AGENT_STATUS_STORE_LIMITS, - AGENT_STATUS_STORE_SNAPSHOT_VERSION, - type AgentStatusFactRecord, - type AgentStatusStoreSnapshot, - type AgentStatusTombstoneRecord -} from './agent-status-store-contract' -import type { AgentStatusParentRecord } from './agent-status-store-parent' -import type { AgentStatusStoreState } from './agent-status-store-state' -import { measureUtf8ByteLength } from './utf8-byte-limits' - -/** Either the snapshot header or a single owner/record measured while accumulating the budget. */ -type AgentStatusStoreByteBudgetRecord = - | AgentStatusStoreSnapshot - | AgentStatusParentRecord - | AgentChildWorkRecord - | AgentChildWorkAliasRecord - | AgentStatusFactRecord - | AgentStatusTombstoneRecord - -const recordBytes = new WeakMap() - -function serializedBytes(record: AgentStatusStoreByteBudgetRecord): number { - const cached = recordBytes.get(record) - if (cached !== undefined) { - return cached - } - const bytes = measureUtf8ByteLength(JSON.stringify(record)).byteLength - if (Object.isFrozen(record)) { - recordBytes.set(record, bytes) - } - return bytes -} - -/** One stored record's share of the serialized snapshot. */ -export function agentStatusStoreRecordBytes( - record: Exclude -): number { - return serializedBytes(record) -} - -/** The snapshot's own envelope, without any records or the commas between them. */ -export function agentStatusStoreHeaderBytes(epoch: string, revision: number): number { - return serializedBytes({ - version: AGENT_STATUS_STORE_SNAPSHOT_VERSION, - epoch, - revision, - parents: [], - children: [], - aliases: [], - facts: [], - tombstones: [] - }) -} - -/** Enforce the complete snapshot budget without allocating a full snapshot. */ -export function agentStatusStoreFitsByteBudget(state: AgentStatusStoreState): boolean { - let bytes = agentStatusStoreHeaderBytes(state.epoch, state.revision) - for (const records of [ - state.parents, - state.children, - state.aliases, - state.facts, - state.tombstones - ]) { - bytes += Math.max(0, records.size - 1) - for (const record of records.values()) { - bytes += serializedBytes(record) - if (bytes > AGENT_STATUS_STORE_LIMITS.serializedBytes) { - return false - } - } - } - return bytes <= AGENT_STATUS_STORE_LIMITS.serializedBytes -} diff --git a/src/shared/agent-status-store-codec.ts b/src/shared/agent-status-store-codec.ts index 326fec5aa725..ba060180f5c8 100644 --- a/src/shared/agent-status-store-codec.ts +++ b/src/shared/agent-status-store-codec.ts @@ -7,8 +7,8 @@ import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' import { - AGENT_STATUS_STORE_LIMITS, AGENT_STATUS_STORE_SNAPSHOT_VERSION, + AGENT_STATUS_STORE_TOMBSTONE_LIMIT, type AgentStatusStoreMutation, type AgentStatusStoreSnapshot, type AgentStatusTombstoneEntity, @@ -25,7 +25,6 @@ import { parseAgentStatusParentRecord } from './agent-status-store-parent' import { parseAgentStatusSubject } from './agent-status-subject' -import { measureUtf8ByteLength } from './utf8-byte-limits' const MAX_EPOCH_LENGTH = 256 const MAX_TOMBSTONE_KEY_LENGTH = 32_768 @@ -50,16 +49,6 @@ function isRevision(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 } -function isWithinSerializedLimit(value: unknown): boolean { - try { - return !measureUtf8ByteLength(JSON.stringify(value), { - stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes - }).exceededLimit - } catch { - return false - } -} - function isBoundedKey(value: unknown, maxLength: number): value is string { if ( typeof value !== 'string' || @@ -111,7 +100,7 @@ export function parseAgentStatusTombstoneRecord(value: unknown): AgentStatusTomb function parseArray( value: unknown, parser: (candidate: unknown) => T | null, - maxLength: number + maxLength = Number.POSITIVE_INFINITY ): T[] | null { if (!Array.isArray(value) || value.length > maxLength) { return null @@ -127,15 +116,8 @@ function parseArray( return parsed } -function parseMutationArray( - value: unknown, - parser: (candidate: unknown) => T | null -): T[] | null { - return parseArray(value, parser, AGENT_STATUS_STORE_LIMITS.mutationEntries) -} - function parseStringArray(value: unknown): string[] | null { - return parseMutationArray(value, (candidate) => + return parseArray(value, (candidate) => isBoundedKey(candidate, MAX_TOMBSTONE_KEY_LENGTH) ? candidate : null ) } @@ -153,7 +135,6 @@ export function parseAgentStatusStoreMutation(value: unknown): AgentStatusStoreM 'tombstones' ] if ( - !isWithinSerializedLimit(value) || !isRecord(value) || !hasOnlyKeys(value, [], optionalKeys) || Object.keys(value).length === 0 @@ -164,29 +145,25 @@ export function parseAgentStatusStoreMutation(value: unknown): AgentStatusStoreM const removeParent = value.removeParent === undefined ? undefined : parseAgentStatusSubject(value.removeParent) const children = - value.children === undefined - ? undefined - : parseMutationArray(value.children, parseAgentChildWorkInput) + value.children === undefined ? undefined : parseArray(value.children, parseAgentChildWorkInput) const removeChildren = value.removeChildren === undefined ? undefined : parseStringArray(value.removeChildren) const aliases = value.aliases === undefined ? undefined - : parseMutationArray(value.aliases, parseAgentChildWorkAliasInput) + : parseArray(value.aliases, parseAgentChildWorkAliasInput) const removeAliases = value.removeAliases === undefined ? undefined : parseStringArray(value.removeAliases) const facts = - value.facts === undefined - ? undefined - : parseMutationArray(value.facts, parseAgentStatusFactInput) + value.facts === undefined ? undefined : parseArray(value.facts, parseAgentStatusFactInput) const removeFacts = value.removeFacts === undefined ? undefined - : parseMutationArray(value.removeFacts, parseAgentStatusFactIdentity) + : parseArray(value.removeFacts, parseAgentStatusFactIdentity) const tombstones = value.tombstones === undefined ? undefined - : parseMutationArray(value.tombstones, parseAgentStatusTombstoneInput) + : parseArray(value.tombstones, parseAgentStatusTombstoneInput) const parsedValues = [ parent, removeParent, @@ -202,18 +179,6 @@ export function parseAgentStatusStoreMutation(value: unknown): AgentStatusStoreM if (sourceValues.some((item, index) => item !== undefined && !parsedValues[index])) { return null } - const mutationEntryCount = [ - children, - removeChildren, - aliases, - removeAliases, - facts, - removeFacts, - tombstones - ].reduce((sum, items) => sum + (items?.length ?? 0), 0) - if (mutationEntryCount > AGENT_STATUS_STORE_LIMITS.mutationEntries) { - return null - } return { ...(parent ? { parent } : {}), ...(removeParent ? { removeParent } : {}), @@ -239,7 +204,6 @@ export function parseAgentStatusStoreSnapshot(value: unknown): AgentStatusStoreS 'tombstones' ] if ( - !isWithinSerializedLimit(value) || !isRecord(value) || !hasOnlyKeys(value, keys) || value.version !== AGENT_STATUS_STORE_SNAPSHOT_VERSION || @@ -248,26 +212,14 @@ export function parseAgentStatusStoreSnapshot(value: unknown): AgentStatusStoreS ) { return null } - const parents = parseArray( - value.parents, - parseAgentStatusParentRecord, - AGENT_STATUS_STORE_LIMITS.parents - ) - const children = parseArray( - value.children, - parseAgentChildWorkRecord, - AGENT_STATUS_STORE_LIMITS.children - ) - const aliases = parseArray( - value.aliases, - parseAgentChildWorkAliasRecord, - AGENT_STATUS_STORE_LIMITS.aliases - ) - const facts = parseArray(value.facts, parseAgentStatusFactRecord, AGENT_STATUS_STORE_LIMITS.facts) + const parents = parseArray(value.parents, parseAgentStatusParentRecord) + const children = parseArray(value.children, parseAgentChildWorkRecord) + const aliases = parseArray(value.aliases, parseAgentChildWorkAliasRecord) + const facts = parseArray(value.facts, parseAgentStatusFactRecord) const tombstones = parseArray( value.tombstones, parseAgentStatusTombstoneRecord, - AGENT_STATUS_STORE_LIMITS.tombstones + AGENT_STATUS_STORE_TOMBSTONE_LIMIT ) return parents && children && aliases && facts && tombstones ? { diff --git a/src/shared/agent-status-store-commit.ts b/src/shared/agent-status-store-commit.ts index 2ea07e07f5df..2174c34aed2f 100644 --- a/src/shared/agent-status-store-commit.ts +++ b/src/shared/agent-status-store-commit.ts @@ -3,11 +3,8 @@ // valid, so a record the mutation did not touch, and whose dependencies it did not touch, still is. import type { AgentStatusStoreMutation } from './agent-status-store-contract' -import { agentStatusStoreHeaderBytes } from './agent-status-store-byte-budget' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' import { commitAgentStatusStoreIndexes, - draftedRecordBytes, inMapOrder, type AgentStatusStoreDrafts, type AgentStatusStoreIndexes @@ -17,7 +14,6 @@ import { type AgentStatusStoreMutationTables } from './agent-status-store-mutation' import { - agentStatusStoreSizesFit, storedAliasIsValid, storedChildIsValid, storedFactIsValid, @@ -156,19 +152,6 @@ function touchedAreValid( return true } -function fitsByteBudget( - epoch: string, - revision: number, - drafts: AgentStatusStoreDrafts, - recordBytes: Record -): boolean { - let bytes = agentStatusStoreHeaderBytes(epoch, revision) - for (const name of ['parents', 'children', 'aliases', 'facts', 'tombstones'] as const) { - bytes += Math.max(0, drafts[name].size - 1) + recordBytes[name] - } - return bytes <= AGENT_STATUS_STORE_LIMITS.serializedBytes -} - /** Apply one mutation to the committed store in place; false (and nothing changed) on refusal. */ export function commitAgentStatusStoreMutation( state: AgentStatusStoreState, @@ -179,16 +162,11 @@ export function commitAgentStatusStoreMutation( const { drafts, tables } = draftTables(state, indexes, revision) if ( !applyAgentStatusStoreMutationSteps(tables, mutation, revision) || - !agentStatusStoreSizesFit(drafts) || !touchedAreValid(drafts, tables) ) { return false } - const recordBytes = draftedRecordBytes(indexes, drafts) - if (!fitsByteBudget(state.epoch, revision, drafts, recordBytes)) { - return false - } - commitAgentStatusStoreIndexes(indexes, drafts, recordBytes) + commitAgentStatusStoreIndexes(indexes, drafts) drafts.parents.commitInto(state.parents) drafts.children.commitInto(state.children) drafts.aliases.commitInto(state.aliases) diff --git a/src/shared/agent-status-store-contract.ts b/src/shared/agent-status-store-contract.ts index 8c9fc96b1c32..5464d73d79b6 100644 --- a/src/shared/agent-status-store-contract.ts +++ b/src/shared/agent-status-store-contract.ts @@ -7,16 +7,7 @@ import type { AgentStatusParentInput, AgentStatusParentRecord } from './agent-st import type { AgentStatusSubject } from './agent-status-subject' export const AGENT_STATUS_STORE_SNAPSHOT_VERSION = 1 as const -export const AGENT_STATUS_STORE_LIMITS = { - parents: 2_048, - children: 8_192, - aliases: 16_384, - facts: 16_384, - tombstones: 1_024, - mutationEntries: 2_048, - serializedBytes: 16 * 1024 * 1024 -} as const - +export const AGENT_STATUS_STORE_TOMBSTONE_LIMIT = 1_024 export const AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS = 4_096 export type AgentStatusFactValue = string | number | boolean | null diff --git a/src/shared/agent-status-store-in-place.test.ts b/src/shared/agent-status-store-in-place.test.ts index 0d7720cb0dab..85d77eaf5a04 100644 --- a/src/shared/agent-status-store-in-place.test.ts +++ b/src/shared/agent-status-store-in-place.test.ts @@ -6,7 +6,7 @@ import { createAgentStatusStore } from './agent-status-store' import { parseAgentStatusStoreMutation } from './agent-status-store-codec' import { commitAgentStatusStoreMutation } from './agent-status-store-commit' import { - AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_TOMBSTONE_LIMIT, AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS, type AgentStatusStoreMutation } from './agent-status-store-contract' @@ -122,7 +122,6 @@ function indexContents(indexes: AgentStatusStoreIndexes) { const ranked = (order: Map) => [...order].sort((left, right) => left[1] - right[1]).map(([key]) => key) return { - recordBytes: indexes.recordBytes, childrenByParent: sets(indexes.childrenByParent), factsByParent: sets(indexes.factsByParent), aliasesByChild: sets(indexes.aliasesByChild), @@ -149,7 +148,6 @@ describe('AgentStatusStore applied in place', () => { if (mutation) { commitAgentStatusStoreMutation(state, indexes, mutation, state.revision + 1) } - // The running byte total is only observable at the budget, so it is held to a rebuild. expect({ step, ...indexContents(indexes) }).toEqual({ step, ...indexContents(indexAgentStatusStoreState(state)) @@ -190,7 +188,7 @@ describe('AgentStatusStore applied in place', () => { it('keeps the newest tombstones in order when their count overflows', () => { const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) const childWorkIds = Array.from( - { length: AGENT_STATUS_STORE_LIMITS.tombstones }, + { length: AGENT_STATUS_STORE_TOMBSTONE_LIMIT }, (_, index) => `removed-${index}` ) for (const mutation of [{ removeChildren: ['oldest'] }, { removeChildren: childWorkIds }]) { diff --git a/src/shared/agent-status-store-indexes.ts b/src/shared/agent-status-store-indexes.ts index c423ab989b41..2a9858bda77e 100644 --- a/src/shared/agent-status-store-indexes.ts +++ b/src/shared/agent-status-store-indexes.ts @@ -6,7 +6,6 @@ import { type AgentChildWorkAliasRecord } from './agent-status-child-work-alias' import type { AgentChildWorkRecord } from './agent-status-child-work' -import { agentStatusStoreRecordBytes } from './agent-status-store-byte-budget' import type { AgentStatusFactRecord, AgentStatusTombstoneRecord @@ -22,8 +21,6 @@ type TableName = (typeof TABLE_NAMES)[number] type OrderedTableName = Exclude export type AgentStatusStoreIndexes = { - /** Serialized bytes of every record per table, for the snapshot budget. */ - recordBytes: Record childrenByParent: Map> factsByParent: Map> aliasesByChild: Map> @@ -109,7 +106,6 @@ function indexTombstone( export function indexAgentStatusStoreState(state: AgentStatusStoreState): AgentStatusStoreIndexes { const indexes: AgentStatusStoreIndexes = { - recordBytes: { parents: 0, children: 0, aliases: 0, facts: 0, tombstones: 0 }, childrenByParent: new Map(), factsByParent: new Map(), aliasesByChild: new Map(), @@ -118,48 +114,25 @@ export function indexAgentStatusStoreState(state: AgentStatusStoreState): AgentS order: { children: new Map(), aliases: new Map(), facts: new Map(), tombstones: new Map() }, nextOrder: 0 } - for (const parent of state.parents.values()) { - indexes.recordBytes.parents += agentStatusStoreRecordBytes(parent) - } for (const [id, child] of state.children) { - indexes.recordBytes.children += agentStatusStoreRecordBytes(child) indexes.order.children.set(id, indexes.nextOrder++) indexChild(indexes, id, child, true) } for (const [key, alias] of state.aliases) { - indexes.recordBytes.aliases += agentStatusStoreRecordBytes(alias) indexes.order.aliases.set(key, indexes.nextOrder++) indexAlias(indexes, key, alias, true) } for (const [key, fact] of state.facts) { - indexes.recordBytes.facts += agentStatusStoreRecordBytes(fact) indexes.order.facts.set(key, indexes.nextOrder++) indexFact(indexes, key, fact, true) } for (const [key, tombstone] of state.tombstones) { - indexes.recordBytes.tombstones += agentStatusStoreRecordBytes(tombstone) indexes.order.tombstones.set(key, indexes.nextOrder++) indexTombstone(indexes, key, tombstone, true) } return indexes } -/** Record bytes per table once the drafts land. */ -export function draftedRecordBytes( - indexes: AgentStatusStoreIndexes, - drafts: AgentStatusStoreDrafts -): Record { - const bytes = { ...indexes.recordBytes } - for (const name of TABLE_NAMES) { - for (const { previous, next } of drafts[name].touched()) { - bytes[name] += - (next ? agentStatusStoreRecordBytes(next) : 0) - - (previous ? agentStatusStoreRecordBytes(previous) : 0) - } - } - return bytes -} - function moveIndex( draft: AgentStatusStoreDraftTable, index: (key: string, record: V, add: boolean) => void @@ -191,10 +164,8 @@ function moveOrder( /** Move every index by what the drafts changed; call before the drafts land in the maps. */ export function commitAgentStatusStoreIndexes( indexes: AgentStatusStoreIndexes, - drafts: AgentStatusStoreDrafts, - recordBytes: Record + drafts: AgentStatusStoreDrafts ): void { - indexes.recordBytes = recordBytes moveIndex(drafts.children, (key, child, add) => indexChild(indexes, key, child, add)) moveIndex(drafts.aliases, (key, alias, add) => indexAlias(indexes, key, alias, add)) moveIndex(drafts.facts, (key, fact, add) => indexFact(indexes, key, fact, add)) diff --git a/src/shared/agent-status-store-mutation.ts b/src/shared/agent-status-store-mutation.ts index d22f4e17c9f8..9849c3c4652f 100644 --- a/src/shared/agent-status-store-mutation.ts +++ b/src/shared/agent-status-store-mutation.ts @@ -13,7 +13,7 @@ import type { AgentStatusTombstoneRecord } from './agent-status-store-contract' import { - AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_TOMBSTONE_LIMIT, AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS } from './agent-status-store-contract' import { parseAgentStatusFactRecord } from './agent-status-store-fact-codec' @@ -63,7 +63,7 @@ function addTombstone( function compactTombstones(state: Tables): void { for (const [key, tombstone] of state.tombstones.entries()) { if ( - state.tombstones.size <= AGENT_STATUS_STORE_LIMITS.tombstones && + state.tombstones.size <= AGENT_STATUS_STORE_TOMBSTONE_LIMIT && state.revision - tombstone.revision < AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS ) { break diff --git a/src/shared/agent-status-store-persistence.ts b/src/shared/agent-status-store-persistence.ts index ffab63d4557d..b98ad962eb2f 100644 --- a/src/shared/agent-status-store-persistence.ts +++ b/src/shared/agent-status-store-persistence.ts @@ -1,43 +1,27 @@ -import { - AGENT_STATUS_STORE_LIMITS, - type AgentStatusStoreSnapshot -} from './agent-status-store-contract' +import type { AgentStatusStoreSnapshot } from './agent-status-store-contract' import { parseAgentStatusStoreSnapshot } from './agent-status-store-codec' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -import { measureUtf8ByteLength } from './utf8-byte-limits' -const SNAPSHOT_STRUCTURE_LIMITS = { - structuralTokens: AGENT_STATUS_STORE_LIMITS.serializedBytes, - nestingDepth: 32 -} as const +const SNAPSHOT_NESTING_DEPTH = 32 -function isWithinByteLimit(value: string): boolean { - return !measureUtf8ByteLength(value, { - stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes - }).exceededLimit -} - -/** Bounded persistence form; callers write the returned string with their existing owner. */ +/** Persistence form; callers write the returned string with their existing owner. */ export function serializeAgentStatusStoreSnapshot(snapshot: AgentStatusStoreSnapshot): string { const parsed = parseAgentStatusStoreSnapshot(snapshot) if (!parsed) { throw new Error('Invalid agent status store snapshot') } - const serialized = JSON.stringify(parsed) - if (!isWithinByteLimit(serialized)) { - throw new Error('Agent status store snapshot exceeds its serialized-byte limit') - } - return serialized + return JSON.stringify(parsed) } export function deserializeAgentStatusStoreSnapshot( serialized: string ): AgentStatusStoreSnapshot | null { - if (!isWithinByteLimit(serialized)) { - return null - } try { - assertJsonTextStructureWithinLimits(serialized, SNAPSHOT_STRUCTURE_LIMITS) + // Size follows the store, which its owners' lifetimes bound; only nesting is limited here. + assertJsonTextStructureWithinLimits(serialized, { + structuralTokens: serialized.length, + nestingDepth: SNAPSHOT_NESTING_DEPTH + }) return parseAgentStatusStoreSnapshot(JSON.parse(serialized)) } catch { return null diff --git a/src/shared/agent-status-store-state.ts b/src/shared/agent-status-store-state.ts index 20c6d9ea8759..207b2f49bc23 100644 --- a/src/shared/agent-status-store-state.ts +++ b/src/shared/agent-status-store-state.ts @@ -9,10 +9,9 @@ import { type AgentChildWorkRecord } from './agent-status-child-work' import { parseAgentChildWorkRecord } from './agent-status-child-work-codec' -import { agentStatusStoreFitsByteBudget } from './agent-status-store-byte-budget' import { - AGENT_STATUS_STORE_LIMITS, AGENT_STATUS_STORE_SNAPSHOT_VERSION, + AGENT_STATUS_STORE_TOMBSTONE_LIMIT, AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS, type AgentStatusFactIdentity, type AgentStatusFactRecord, @@ -112,22 +111,6 @@ export type AgentStatusStoreRecordLookups = { tombstones: Lookup } -export function agentStatusStoreSizesFit(sizes: { - parents: { size: number } - children: { size: number } - aliases: { size: number } - facts: { size: number } - tombstones: { size: number } -}): boolean { - return ( - sizes.parents.size <= AGENT_STATUS_STORE_LIMITS.parents && - sizes.children.size <= AGENT_STATUS_STORE_LIMITS.children && - sizes.aliases.size <= AGENT_STATUS_STORE_LIMITS.aliases && - sizes.facts.size <= AGENT_STATUS_STORE_LIMITS.facts && - sizes.tombstones.size <= AGENT_STATUS_STORE_LIMITS.tombstones - ) -} - export function storedParentIsValid( lookups: AgentStatusStoreRecordLookups, key: string, @@ -196,7 +179,7 @@ export function storedTombstoneIsValid( /** Every invariant over the whole store: for snapshot restore, and the oracle mutations are held to. */ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boolean { - if (!agentStatusStoreSizesFit(state)) { + if (state.tombstones.size > AGENT_STATUS_STORE_TOMBSTONE_LIMIT) { return false } for (const [key, parent] of state.parents) { @@ -224,7 +207,7 @@ export function validateAgentStatusStoreState(state: AgentStatusStoreState): boo return false } } - return agentStatusStoreFitsByteBudget(state) + return true } export function snapshotFromAgentStatusStoreState( diff --git a/src/shared/agent-status-store-status-codec.ts b/src/shared/agent-status-store-status-codec.ts index 21f5bcb135e8..8fd4786b959f 100644 --- a/src/shared/agent-status-store-status-codec.ts +++ b/src/shared/agent-status-store-status-codec.ts @@ -48,16 +48,22 @@ function isBoundedString(value: unknown): value is string { function copyJsonRecord(value: unknown): Record | null { let serialized: string + let bounded: string try { serialized = JSON.stringify(value) + // Why: a pane's subagent roster has no count limit; each row's fields are bounded on their own. + bounded = + isRecord(value) && Object.hasOwn(value, 'subagents') + ? JSON.stringify({ ...value, subagents: undefined }) + : serialized } catch { return null } - if (measureUtf8ByteLength(serialized, { stopAfterBytes: MAX_STATUS_BYTES }).exceededLimit) { + if (measureUtf8ByteLength(bounded, { stopAfterBytes: MAX_STATUS_BYTES }).exceededLimit) { return null } try { - assertJsonTextStructureWithinLimits(serialized, STATUS_STRUCTURE_LIMITS) + assertJsonTextStructureWithinLimits(bounded, STATUS_STRUCTURE_LIMITS) const parsed: unknown = JSON.parse(serialized) return isRecord(parsed) ? parsed : null } catch { diff --git a/src/shared/agent-status-store.test.ts b/src/shared/agent-status-store.test.ts index 86ddd90e953d..dfbc149d8921 100644 --- a/src/shared/agent-status-store.test.ts +++ b/src/shared/agent-status-store.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentStatusIpcPayload } from './agent-status-ipc-payload' import type { AgentChildWorkAliasInput } from './agent-status-child-work-alias' import type { AgentChildWorkInput } from './agent-status-child-work' import { serializeAgentStatusProviderAliasKey } from './agent-status-run-alias-index' import { createAgentStatusStore } from './agent-status-store' import { - AGENT_STATUS_STORE_LIMITS, + AGENT_STATUS_STORE_TOMBSTONE_LIMIT, AGENT_STATUS_STORE_TOMBSTONE_RETENTION_REVISIONS, type AgentStatusStoreSnapshot } from './agent-status-store-contract' @@ -260,12 +260,12 @@ describe('AgentStatusStore', () => { expect( store.applyMutation({ removeChildren: Array.from( - { length: AGENT_STATUS_STORE_LIMITS.tombstones + 1 }, + { length: AGENT_STATUS_STORE_TOMBSTONE_LIMIT + 1 }, (_, index) => `unused-child-${index}` ) }) ).not.toBeNull() - expect(store.getSnapshot().tombstones).toHaveLength(AGENT_STATUS_STORE_LIMITS.tombstones) + expect(store.getSnapshot().tombstones).toHaveLength(AGENT_STATUS_STORE_TOMBSTONE_LIMIT) expect(store.getSnapshot().tombstones.some((item) => item.entity === 'parent')).toBe(false) expect(replica.applySnapshot(store.getSnapshot())).toBe(true) expect(replica.applyTransportEnvelope(first)).toBe(false) @@ -371,24 +371,8 @@ describe('AgentStatusStore', () => { expect(restarted.getChildren(parent)[0]?.firstObservedAt).toBe(12) }) - it('fails closed before parsing oversized persistence payloads or allocating huge mutations', () => { - const parse = vi.spyOn(JSON, 'parse') - const oversized = ' '.repeat(AGENT_STATUS_STORE_LIMITS.serializedBytes + 1) - - expect(deserializeAgentStatusStoreSnapshot(oversized)).toBeNull() - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - + it('refuses a parent status beyond its field limit', () => { const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) - expect( - store.applyMutation({ - removeChildren: Array.from( - { length: AGENT_STATUS_STORE_LIMITS.mutationEntries + 1 }, - (_, index) => `child-${index}` - ) - }) - ).toBeNull() - expect(store.getSnapshot().revision).toBe(0) const scopedSubject = subject() expect( store.applyMutation({ @@ -407,6 +391,22 @@ describe('AgentStatusStore', () => { ).toBeNull() }) + it('accepts a parent status whose subagent roster outgrows the status field limit', () => { + const store = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) + const scopedSubject = subject() + const subagents = Array.from({ length: 2_000 }, (_, index) => ({ + id: `a${index}`, + state: 'working' as const, + startedAt: 1, + description: 'x'.repeat(200) + })) + expect( + store.applyMutation({ + parent: { subject: scopedSubject, status: status(scopedSubject, { subagents }) } + }) + ).not.toBeNull() + }) + it('rejects malformed and scope-mismatched snapshots without changing state', () => { const { store } = populatedStore() const before = store.getSnapshot() diff --git a/src/shared/agent-status-subagent-snapshot.ts b/src/shared/agent-status-subagent-snapshot.ts index 4f10b1e89bbf..9ce8952729b6 100644 --- a/src/shared/agent-status-subagent-snapshot.ts +++ b/src/shared/agent-status-subagent-snapshot.ts @@ -24,9 +24,6 @@ export type AgentSubagentSnapshot = { startedAt: number } -/** Maximum subagent child rows carried per status entry. Bounds per-pane cache - * and IPC fanout against a runaway spawner. */ -export const AGENT_STATUS_MAX_SUBAGENTS = 32 export const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 function normalizeSubagentSnapshot( @@ -81,9 +78,6 @@ function readAgentSubagents( const snapshot = normalizeSubagentSnapshot(item, allowFutureState) if (snapshot) { normalized.push(snapshot) - if (normalized.length >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } } } return normalized.length > 0 ? normalized : undefined diff --git a/src/shared/agent-status-transport-envelope.test.ts b/src/shared/agent-status-transport-envelope.test.ts index 81007bc7e395..6e5307bc4f4f 100644 --- a/src/shared/agent-status-transport-envelope.test.ts +++ b/src/shared/agent-status-transport-envelope.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { createAgentStatusStore } from './agent-status-store' -import { AGENT_STATUS_STORE_LIMITS } from './agent-status-store-contract' import { makeStructuredAgentStatusSubject } from './agent-status-subject' import { deserializeAgentStatusTransportEnvelope, @@ -68,7 +67,7 @@ describe('agent status transport envelope', () => { expect(replica.getSnapshot().facts.map((fact) => fact.key)).toEqual(['retained']) }) - it('round-trips a mutation envelope and fails closed on malformed or oversized input', () => { + it('round-trips a mutation envelope and fails closed on malformed input', () => { const authority = createAgentStatusStore({ epoch: 'epoch-a', mode: 'authority' }) const mutation = authority.applyMutation({ parent: { subject: parent } }) expect(mutation).not.toBeNull() @@ -84,15 +83,6 @@ describe('agent status transport envelope', () => { JSON.stringify({ ...mutation, revision: mutation.revision + 2 }) ) ).toBeNull() - - const parse = vi.spyOn(JSON, 'parse') - expect( - deserializeAgentStatusTransportEnvelope( - ' '.repeat(AGENT_STATUS_STORE_LIMITS.serializedBytes + 1) - ) - ).toBeNull() - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() }) it('does not resurrect an exact removal through stale replay', () => { diff --git a/src/shared/agent-status-transport-envelope.ts b/src/shared/agent-status-transport-envelope.ts index 51fd0800db84..9b1134a58762 100644 --- a/src/shared/agent-status-transport-envelope.ts +++ b/src/shared/agent-status-transport-envelope.ts @@ -1,7 +1,6 @@ -import { - AGENT_STATUS_STORE_LIMITS, - type AgentStatusStoreMutation, - type AgentStatusStoreSnapshot +import type { + AgentStatusStoreMutation, + AgentStatusStoreSnapshot } from './agent-status-store-contract' import { isAgentStatusStoreEpoch, @@ -9,10 +8,10 @@ import { parseAgentStatusStoreSnapshot } from './agent-status-store-codec' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -import { measureUtf8ByteLength } from './utf8-byte-limits' +// Why: the store has no size limit, so only nesting is bounded; any envelope it can serialize must parse. const ENVELOPE_STRUCTURE_LIMITS = { - structuralTokens: 512 * 1024, + structuralTokens: Number.MAX_SAFE_INTEGER, nestingDepth: 40 } as const @@ -83,27 +82,12 @@ export function serializeAgentStatusTransportEnvelope( if (!parsed) { throw new Error('Invalid agent status transport envelope') } - const serialized = JSON.stringify(parsed) - if ( - measureUtf8ByteLength(serialized, { - stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes - }).exceededLimit - ) { - throw new Error('Agent status transport envelope exceeds its serialized-byte limit') - } - return serialized + return JSON.stringify(parsed) } export function deserializeAgentStatusTransportEnvelope( serialized: string ): AgentStatusTransportEnvelope | null { - if ( - measureUtf8ByteLength(serialized, { - stopAfterBytes: AGENT_STATUS_STORE_LIMITS.serializedBytes - }).exceededLimit - ) { - return null - } try { assertJsonTextStructureWithinLimits(serialized, ENVELOPE_STRUCTURE_LIMITS) return parseAgentStatusTransportEnvelope(JSON.parse(serialized)) diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index b591fae683c6..59bbfff318a7 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -8,7 +8,6 @@ import { pickParsedAgentStatusPayload, AGENT_STATUS_JSON_STRUCTURE_LIMITS, AGENT_STATUS_MAX_FIELD_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, AGENT_STATUS_TOOL_NAME_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, @@ -572,7 +571,7 @@ Fix dispatch fallback preview for normalized status prompts` } }) - it('normalizes the subagents field, dropping invalid entries and bounding count', () => { + it('normalizes the subagents field, dropping invalid entries and keeping every valid one', () => { const result = parseAgentStatusPayload( JSON.stringify({ state: 'working', @@ -583,7 +582,7 @@ Fix dispatch fallback preview for normalized status prompts` { id: '', state: 'working', startedAt: 1 }, { id: 'bad-state', state: 'running', startedAt: 1 }, 'garbage', - ...Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 5 }, (_, i) => ({ + ...Array.from({ length: 37 }, (_, i) => ({ id: `extra-${i}`, state: 'idle', startedAt: i @@ -591,7 +590,7 @@ Fix dispatch fallback preview for normalized status prompts` ] }) ) - expect(result?.subagents?.length).toBe(AGENT_STATUS_MAX_SUBAGENTS) + expect(result?.subagents?.length).toBe(40) expect(result?.subagents?.[0]).toEqual({ id: 'a1', state: 'working', diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index a61ec507be84..9e9827884fba 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -35,7 +35,6 @@ export { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' export { - AGENT_STATUS_MAX_SUBAGENTS, agentSubagentsEqual, type AgentSubagentSnapshot, type AgentSubagentState diff --git a/src/shared/ai-vault-session-depth.test.ts b/src/shared/ai-vault-session-depth.test.ts index c262d5214495..f8aa4b0c5486 100644 --- a/src/shared/ai-vault-session-depth.test.ts +++ b/src/shared/ai-vault-session-depth.test.ts @@ -65,7 +65,7 @@ describe('Agent Session History depth', () => { expect(aiVaultScanLimit({ unlimited: true })).toBe(Number.POSITIVE_INFINITY) }) - it('keeps the newest global and scoped sessions when truncating', () => { + it('keeps every scoped session and the newest out-of-scope ones when truncating', () => { const loaded = result([ session('global-1', '/other', 6), session('global-2', '/other', 5), @@ -79,10 +79,28 @@ describe('Agent Session History depth', () => { 'global-1', 'global-2', 'scoped-1', - 'scoped-2' + 'scoped-2', + 'scoped-3' + ]) + expect(truncateAiVaultListResult(loaded, 2).sessions.map(({ id }) => id)).toEqual([ + 'global-1', + 'global-2' ]) expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded) }) + + // Repeat opens served from a cache used to drop to `depth` scoped rows (341 -> 250 in QA). + it('returns a scan result with more scoped rows than the depth unchanged', () => { + const scanned = result([ + ...Array.from({ length: 3 }, (_, i) => session(`global-${i}`, '/other', 100 - i)), + ...Array.from({ length: 5 }, (_, i) => session(`codex-${i}`, '/repo', 50 - i)), + ...Array.from({ length: 4 }, (_, i) => session(`claude-${i}`, '/repo/app', 40 - i)) + ]) + + const cachedHit = truncateAiVaultListResult(scanned, 3, ['/repo']) + expect(cachedHit.sessions).toEqual(scanned.sessions) + expect(truncateAiVaultListResult(cachedHit, 3, ['/repo']).sessions).toEqual(scanned.sessions) + }) }) describe('Agent Session History scope truncation', () => { diff --git a/src/shared/ai-vault-session-depth.ts b/src/shared/ai-vault-session-depth.ts index 91c183aaf906..423c01816396 100644 --- a/src/shared/ai-vault-session-depth.ts +++ b/src/shared/ai-vault-session-depth.ts @@ -30,6 +30,19 @@ export function aiVaultSessionDepthCovers( return cached === 'unlimited' || (requested !== 'unlimited' && cached >= requested) } +// A deeper scan keeps every scoped row it found, so a scoped request reuses only a scan of its +// own depth; a lowered depth otherwise shows the deeper scan's scoped rows again. +export function aiVaultCachedDepthServes( + cached: AiVaultSessionDepth, + requested: AiVaultSessionDepth, + scopePaths: readonly string[] = [] +): boolean { + return scopePaths.length > 0 ? cached === requested : aiVaultSessionDepthCovers(cached, requested) +} + +// Depth bounds only out-of-scope rows: the scan already budgets in-scope rows per source, +// and its out-of-scope rows are its capped newest set, so a scan result passes through +// unchanged and a cache hit can never show less than the scan it came from. export function truncateAiVaultListResult( result: AiVaultListResult, depth: AiVaultSessionDepth, @@ -38,20 +51,11 @@ export function truncateAiVaultListResult( if (depth === 'unlimited') { return result } - const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id)) - if (scopePaths.length > 0) { - const inScope = createAiVaultScopeCwdMatcher(scopePaths) - let scopedCount = 0 - for (const session of result.sessions) { - if (inScope(session.cwd, session.filePath)) { - selectedIds.add(session.id) - if (++scopedCount >= depth) { - break - } - } - } - } - const sessions = result.sessions.filter((session) => selectedIds.has(session.id)) + const inScope = scopePaths.length > 0 ? createAiVaultScopeCwdMatcher(scopePaths) : null + let outOfScopeCount = 0 + const sessions = result.sessions.filter( + (session) => inScope?.(session.cwd, session.filePath) === true || outOfScopeCount++ < depth + ) return { ...result, sessions, diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts index ed94e821ce5c..bbffb79a1edf 100644 --- a/src/shared/claude-background-task-inventory.ts +++ b/src/shared/claude-background-task-inventory.ts @@ -1,4 +1,3 @@ -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { classifyClaudeBackgroundTaskKind } from './claude-background-task-kind' import { isAgentChildWorkKind } from './agent-status-child-work-liveness' @@ -94,12 +93,6 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record= AGENT_STATUS_MAX_SUBAGENTS) { - // Why: a capped inventory cannot prove a tracked id is absent; callers - // must retain unlisted rows rather than deleting live overflow tasks. - truncated = true - continue - } tasks.push({ id: obj.id.trim(), agentType: typeof obj.agent_type === 'string' ? obj.agent_type : undefined, diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts index b823dd532387..03c2b490cbbf 100644 --- a/src/shared/claude-background-task-status.test.ts +++ b/src/shared/claude-background-task-status.test.ts @@ -11,7 +11,6 @@ import { seedClaudeSubagentRosterFromSnapshots } from './agent-hook-listener/providers/claude-roster-state' import { normalizeHookPayload } from './agent-hook-listener' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { makePaneKey } from './stable-pane-id' @@ -30,8 +29,8 @@ function claudeEvent(state: HookListenerState, paneKey: string, payload: Record< } describe('Claude background task status', () => { - it('finds pending monitor work after the visible child-row cap', () => { - const agentTasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + it('lists every agent row and still finds pending monitor work after them', () => { + const agentTasks = Array.from({ length: 40 }, (_, index) => ({ id: `agent-${index}`, type: 'subagent', status: 'running' @@ -40,8 +39,8 @@ describe('Claude background task status', () => { background_tasks: [...agentTasks, { id: 'monitor-1', type: 'monitor', status: 'pending' }] }) - expect(result).toMatchObject({ truncated: true, hasRunningNonAgentTask: true }) - expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(result).toMatchObject({ truncated: false, hasRunningNonAgentTask: true }) + expect(result.tasks).toHaveLength(40) const state = createHookListenerState() expect( diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 3ccb8b67a820..7bd53290ffb1 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory' import { claudeRosterHasRuntimeWorkingSubagent, @@ -114,58 +113,12 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('drops new spawns at the cap rather than evicting working children', () => { + it('keeps 40 working subagents', () => { const roster: ClaudeSubagentRoster = new Map() - for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + for (let i = 0; i < 40; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) } - // Why: every tracked entry is working โ€” nothing is safe to evict, so the - // overflow spawn is dropped (it would be invisible past the wire cap). - upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) - expect(roster.has('overflow')).toBe(false) - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - - // Once a child finishes, a new spawn takes the freed slot. - stopClaudeSubagent(roster, 'a0') - upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) - expect(roster.has('replacement')).toBe(true) - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - }) - - it('evicts the oldest idle teammate to admit a new spawn at the cap', () => { - const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'aold-teammate-6d3cb5b5', {}, 1) - upsertWorkingClaudeSubagent(roster, 'anew-teammate-6d3cb5b5', {}, 2) - stopClaudeSubagent(roster, 'aold-teammate-6d3cb5b5') - stopClaudeSubagent(roster, 'anew-teammate-6d3cb5b5') - for (let i = 2; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { - upsertWorkingClaudeSubagent(roster, `a${i}`, {}, 10 + i) - } - // Why: a parked idle row is the only thing safe to displace โ€” a working - // spawn must never be dropped just because idle teammates fill the cap. - upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) - expect(roster.has('overflow')).toBe(true) - expect(roster.has('aold-teammate-6d3cb5b5')).toBe(false) - expect(roster.has('anew-teammate-6d3cb5b5')).toBe(true) - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - }) - - it('reconciles stale entries before adding replacement tasks at the cap', () => { - const roster: ClaudeSubagentRoster = new Map() - for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { - upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) - } - const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, index) => - task({ id: index === 0 ? 'replacement' : `a${index}` }) - ) - - // Why: a complete inventory can replace a stale child while the roster is - // full. Reap the stale slot first so the new live child keeps the done-gate. - foldClaudeBackgroundTasksIntoRoster(roster, tasks, 999) - - expect(roster.has('a0')).toBe(false) - expect(roster.has('replacement')).toBe(true) - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) + expect(claudeRosterToSnapshots(roster)).toHaveLength(40) }) it('reads only agent-typed background_tasks entries', () => { @@ -208,17 +161,6 @@ describe('claude-subagent-roster', () => { expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false) }) - it('marks a background task inventory truncated after the snapshot cap', () => { - const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ - id: `a${index}`, - type: 'subagent', - status: 'running' - })) - const result = readClaudeBackgroundAgentTasks({ background_tasks: tasks }) - expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) - expect(result.truncated).toBe(true) - }) - it('trusts id-exact subagent matches and ignores teammate-typed entries', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) @@ -319,19 +261,19 @@ describe('claude-subagent-roster', () => { it('retains an unlisted live child when the background task inventory was truncated', () => { const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'alive-after-cap', {}, 100) + upsertWorkingClaudeSubagent(roster, 'alive-unlisted', {}, 100) const parsed = readClaudeBackgroundAgentTasks({ - background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ - id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, - type: 'subagent', - status: 'running' - })) + background_tasks: [ + { id: 'a0', type: 'subagent', status: 'running' }, + { id: 'unreadable', status: 'running' } + ] }) + expect(parsed.truncated).toBe(true) foldClaudeBackgroundTasksIntoRoster(roster, parsed.tasks, 200, { inventoryComplete: !parsed.truncated }) - expect(roster.has('alive-after-cap')).toBe(true) + expect(roster.has('alive-unlisted')).toBe(true) }) it('recreates unmatched running one-shot subagents after a listener restart', () => { @@ -428,7 +370,7 @@ describe('claude-subagent-roster', () => { it('does not idle an unrelated one-shot whose agent_type matches the teammate name', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: a teammate's start hook may be missing (restart, cap, or lost + // Why: a teammate's start hook may be missing (restart or lost // delivery). Agent type is not identity, so its idle hook must not park // another live child that happens to use the same type name. upsertWorkingClaudeSubagent(roster, 'aoneshot00000001', { agentType: 'reviewer' }, 100) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 533d6233f5d0..2ea71d279aa6 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -1,4 +1,4 @@ -import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types' +import type { AgentSubagentSnapshot } from './agent-status-types' import type { ClaudeBackgroundAgentTask } from './claude-background-task-inventory' /** Mirrors the wire-normalization id cap in agent-status-types. Enforced at @@ -93,11 +93,6 @@ export function upsertWorkingClaudeSubagent( existing.restoredFromSnapshot = undefined return } - // Why: beyond the wire cap extra rows would be invisible anyway; idle - // teammates are the only safe eviction โ€” never displace a working child. - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS && !evictOldestIdleClaudeSubagent(roster)) { - return - } roster.set(id, { state: 'working', startedAt: now, @@ -106,22 +101,6 @@ export function upsertWorkingClaudeSubagent( }) } -function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { - let oldestId: string | null = null - let oldestStartedAt = Infinity - for (const [id, tracked] of roster) { - if (tracked.state === 'idle' && tracked.startedAt < oldestStartedAt) { - oldestId = id - oldestStartedAt = tracked.startedAt - } - } - if (oldestId === null) { - return false - } - roster.delete(oldestId) - return true -} - /** SubagentStop. A one-shot child is finished โ€” the row leaves immediately. * A teammate-shaped id is only ending a TURN on claude 2.1.21x (the teammate * stays alive awaiting mail), so its row flips to idle instead โ€” unless a @@ -174,7 +153,6 @@ export function foldClaudeBackgroundTasksIntoRoster( return } const listedIds = new Set() - const pendingRunningTasks = new Map() const hasTeammateTypedTask = tasks.some((task) => task.teammate) for (const task of tasks) { if (task.teammate) { @@ -185,7 +163,6 @@ export function foldClaudeBackgroundTasksIntoRoster( if (existing) { if (!task.running) { roster.delete(task.id) - pendingRunningTasks.delete(task.id) continue } // Why: a Stop can park the row before the lead inventory confirms the @@ -200,7 +177,6 @@ export function foldClaudeBackgroundTasksIntoRoster( continue } if (!task.running) { - pendingRunningTasks.delete(task.id) continue } upsertWorkingClaudeSubagent( @@ -213,10 +189,6 @@ export function foldClaudeBackgroundTasksIntoRoster( if (created) { created.backgroundTasksAuthoritative = true created.listedAsSubagentTask = true - } else { - // Why: a full roster may still contain stale entries that this same - // inventory will reap. Retry after cleanup so a replacement stays live. - pendingRunningTasks.set(task.id, task) } } if (options?.inventoryComplete !== false) { @@ -239,22 +211,6 @@ export function foldClaudeBackgroundTasksIntoRoster( roster.delete(id) } } - for (const task of pendingRunningTasks.values()) { - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { - break - } - upsertWorkingClaudeSubagent( - roster, - task.id, - { agentType: task.agentType, description: task.description }, - now - ) - const created = roster.get(task.id) - if (created) { - created.backgroundTasksAuthoritative = true - created.listedAsSubagentTask = true - } - } } /** Drop restored rows that no current-runtime child activity has confirmed. */ diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts index 9d7598504765..8b35d608689b 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/codex-subagent-roster.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - AGENT_MODEL_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_TYPE_MAX_LENGTH -} from './agent-status-types' +import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' import { codexRosterChildWorkLiveness, codexRosterToSnapshots, @@ -48,22 +44,6 @@ describe('Codex subagent roster', () => { expect(roster.size).toBe(0) }) - it('bounds live storage while admitting a replacement after one child stops', () => { - const roster: CodexSubagentRoster = new Map() - for (let index = 0; index <= AGENT_STATUS_MAX_SUBAGENTS; index += 1) { - upsertCodexSubagent(roster, `child-${index}`, { state: 'working' }, index) - } - - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - expect(roster.has(`child-${AGENT_STATUS_MAX_SUBAGENTS}`)).toBe(false) - - finishCodexSubagent(roster, 'child-0') - upsertCodexSubagent(roster, 'replacement', { state: 'working' }, 100) - - expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) - expect(roster.has('replacement')).toBe(true) - }) - describe('codexRosterChildWorkLiveness', () => { it('reads every child as agent work, a waiting one above the rest, and nothing as a watch loop', () => { const roster: CodexSubagentRoster = new Map() diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 805d459e059e..37f8d6f909e4 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -1,6 +1,5 @@ import { AGENT_MODEL_MAX_LENGTH, - AGENT_STATUS_MAX_SUBAGENTS, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, type AgentSubagentSnapshot @@ -49,9 +48,6 @@ export function upsertCodexSubagent( existing.state = fields.state return } - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { - return - } roster.set(normalizedId, { agentType, description, diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 35f5b4c119c0..2d52ac77d6ad 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -33,7 +33,7 @@ export type CommitMessageModel = { description?: string /** Omit when the model does not expose an effort selector โ€” the UI then hides the dropdown. */ thinkingLevels?: ThinkingLevel[] - /** Required when thinkingLevels is present. */ + /** Omitted when the CLI's configured default is unknown. */ defaultThinkingLevel?: string /** Whether the model exposes Claude's mid-session Fast mode toggle. */ supportsFastMode?: boolean diff --git a/src/shared/commit-message-plan.ts b/src/shared/commit-message-plan.ts index 8fe541c6028e..ee25f52f658d 100644 --- a/src/shared/commit-message-plan.ts +++ b/src/shared/commit-message-plan.ts @@ -10,6 +10,7 @@ import { import { planCustomCommand, tokenizeCustomCommandTemplate } from './commit-message-prompt' import type { TuiAgent } from './tui-agent' import { mergeOpenCodeGenerationArgs } from './opencode-generation-command' +import { isOmpThinkingLevel } from './omp-model-list-probe' // Why: planning is a pure transformation from "user request + prompt text" // into "spawn-ready binary + argv". Keeping it in shared lets both the local @@ -235,6 +236,9 @@ export function planCommitMessageGeneration( return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` } } if (input.thinkingLevel) { + if (input.agentId === 'omp' && !isOmpThinkingLevel(input.thinkingLevel)) { + return { ok: false, error: `OMP does not support thinking "${input.thinkingLevel}".` } + } if (!model.thinkingLevels && spec.modelSource !== 'dynamic') { return { ok: false, diff --git a/src/shared/desktop-browser-bridge-protocol.ts b/src/shared/desktop-browser-bridge-protocol.ts index 17425962c6b6..4b739f7d420c 100644 --- a/src/shared/desktop-browser-bridge-protocol.ts +++ b/src/shared/desktop-browser-bridge-protocol.ts @@ -9,6 +9,11 @@ import { z } from 'zod' export const DESKTOP_BROWSER_BRIDGE_RUNTIME_CAPABILITY = 'browser.desktopBridge.v1' +/** + * Declared by a desktop that understands a `cancel` event. orcad never sends one to a desktop + * without it: an older desktop drops its whole attachment on an event type it cannot parse. + */ +export const DESKTOP_BROWSER_BRIDGE_CANCEL_CAPABILITY = 'browser.desktopBridge.cancel.v1' export const DESKTOP_BROWSER_BRIDGE_UNAVAILABLE_CODE = 'desktop_browser_bridge_unavailable' export const DESKTOP_BROWSER_BRIDGE_NOT_FORWARDED_CODE = 'desktop_browser_bridge_not_forwarded' @@ -120,7 +125,9 @@ export type DesktopBrowserBridgeRequest = z.infer diff --git a/src/shared/disk-baseline-signature.ts b/src/shared/disk-baseline-signature.ts new file mode 100644 index 000000000000..c46ef0d59f96 --- /dev/null +++ b/src/shared/disk-baseline-signature.ts @@ -0,0 +1,54 @@ +// Why: the disk baseline guards against silently overwriting external writes +// (issue #7265), where a hash collision means a missed conflict โ€” so it gets +// two independent FNV lanes plus the length instead of a single 32-bit lane. +export function getDiskBaselineSignature(content: string): string { + return new DiskBaselineSignatureBuilder().update(content).digest() +} + +/** The same signature fed in pieces, so a large file can be hashed between yields. */ +export class DiskBaselineSignatureBuilder { + private hashA = 2166136261 + private hashB = 84696351 + private length = 0 + + update(chunk: string): this { + let hashA = this.hashA + let hashB = this.hashB + for (let i = 0; i < chunk.length; i += 1) { + const code = chunk.charCodeAt(i) + hashA ^= code + hashA = Math.imul(hashA, 16777619) + hashB = Math.imul(hashB ^ code, 1099511627) + } + this.hashA = hashA + this.hashB = hashB + this.length += chunk.length + return this + } + + digest(): string { + return `${(this.hashA >>> 0).toString(16)}-${(this.hashB >>> 0).toString(16)}-${this.length.toString(16)}` + } +} + +/** UTF-16 length a disk baseline signature was taken over, or null when it is not one. */ +export function diskBaselineSignatureLength(signature: string): number | null { + const match = /^[0-9a-f]{1,8}-[0-9a-f]{1,8}-([0-9a-f]{1,13})$/.exec(signature) + if (!match) { + return null + } + const length = Number.parseInt(match[1], 16) + return Number.isSafeInteger(length) ? length : null +} + +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= utf16Length && byteSize <= utf16Length * 3 +} diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index 86f01093e0c5..001e52feee3f 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -92,6 +92,15 @@ describe('execution host registry', () => { ]) }) + it('hides machine-provider SSH targets the same way', () => { + const hosts = buildExecutionHostRegistry({ + repos: [{ connectionId: 'machine-0123', executionHostId: 'ssh:machine-0123' }], + settings: { activeRuntimeEnvironmentId: null }, + sshTargetLabels: new Map([['machine-0123', 'omarchy-dev']]) + }) + expect(hosts.some((h) => h.id.includes('machine-0123'))).toBe(false) + }) + it('hides runtime-owned (ephemeral VM) SSH targets from repo-derived hosts', () => { const hosts = buildExecutionHostRegistry({ // A VM-backed repo carries the hidden runtime-owned target on both fields. diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index bb0d6d92d8d0..058618d76aee 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -5,7 +5,7 @@ import { getRepoExecutionHostId, getSettingsFocusedExecutionHostId, getSshTargetIdForExecutionHost, - isRuntimeOwnedSshTargetId, + isOwnedSshTargetId, normalizeHostPart, parseExecutionHostId, toRuntimeExecutionHostId, @@ -197,12 +197,14 @@ export function buildExecutionHostRegistry(args: { // Why: user-chosen per-host display labels override the derived label so a // rename in the host menu/settings shows everywhere the registry feeds. hostLabelOverrides?: ReadonlyMap + /** Defaults to this process's platform, which only the host itself may use. */ + localHostLabel?: string }): ExecutionHostRegistryEntry[] { const hosts = new Map() hosts.set(LOCAL_EXECUTION_HOST_ID, { id: LOCAL_EXECUTION_HOST_ID, kind: 'local', - label: getLocalExecutionHostLabel(), + label: args.localHostLabel ?? getLocalExecutionHostLabel(), detail: 'This computer', health: 'local' }) @@ -257,14 +259,14 @@ export function buildExecutionHostRegistry(args: { } // Why: a VM-backed repo's executionHostId is `ssh:runtime-ssh-`. Runtime-owned // targets are hidden, so they must not become visible SSH run-target hosts here. - if (parsedHost?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsedHost.targetId)) { + if (parsedHost?.kind === 'ssh' && !isOwnedSshTargetId(parsedHost.targetId)) { sshTargetIds.add(parsedHost.targetId) } } } for (const targetId of args.sshTargetLabels?.keys() ?? []) { const normalized = normalizeHostPart(targetId) - if (normalized && !isRuntimeOwnedSshTargetId(normalized)) { + if (normalized && !isOwnedSshTargetId(normalized)) { sshTargetIds.add(normalized) } } @@ -273,7 +275,7 @@ export function buildExecutionHostRegistry(args: { // Why the host, not `connectionId`: a paired-server repo's `connectionId` is the server's // own SSH target, which this client cannot dial. const targetId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) - if (targetId && !isRuntimeOwnedSshTargetId(targetId)) { + if (targetId && !isOwnedSshTargetId(targetId)) { sshTargetIds.add(targetId) } } diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index e86bccbecbdc..8e3fd83a89ae 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -10,7 +10,9 @@ import { getSettingsFocusedExecutionHostId, getSshTargetIdForExecutionHost, getWorktreeExecutionHostId, + isOwnedSshTargetId, isRepoOnCatalogHost, + isRuntimeOwnedSshTargetId, isUnresolvedOwnerHostId, normalizeExecutionHostOrder, normalizeExecutionHostScope, @@ -218,3 +220,14 @@ describe('isRepoOnCatalogHost', () => { expect(isRepoOnCatalogHost({ executionHostId: 'future:host' })).toBe(false) }) }) + +describe('owned SSH target ids', () => { + it('hides recipe-VM and machine targets but keeps the recipe-VM meaning narrow', () => { + expect(isOwnedSshTargetId('runtime-ssh-vm-1')).toBe(true) + expect(isOwnedSshTargetId('machine-0123456789abcdef0123')).toBe(true) + expect(isOwnedSshTargetId('ssh-1792393612859-abc123')).toBe(false) + expect(isOwnedSshTargetId(null)).toBe(false) + // Only recipe VMs carry "replaced every provision" semantics. + expect(isRuntimeOwnedSshTargetId('machine-0123456789abcdef0123')).toBe(false) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 604271226a5c..47e9d867720e 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -77,12 +77,28 @@ export function toRuntimeExecutionHostId(environmentId: string): `runtime:${stri // SSH/run-target surfaces. The renderer can't read the target.owner field, so it // recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId // (main) builds on this same prefix to keep the two in sync. +// The prefix also means "address and filesystem are replaced every provision", so only +// on-demand-runtime owners may use it; main decides hidden-ness from `owner` alone. export const RUNTIME_OWNED_SSH_TARGET_ID_PREFIX = 'runtime-ssh-' +/** Machine-provider targets: owned like recipe VMs, but persistent, so not `runtime-ssh-`. */ +export const MACHINE_SSH_TARGET_ID_PREFIX = 'machine-' +/** A recipe VM's target, whose address and filesystem are replaced every provision. */ export function isRuntimeOwnedSshTargetId(targetId: string | null | undefined): boolean { return typeof targetId === 'string' && targetId.startsWith(RUNTIME_OWNED_SSH_TARGET_ID_PREFIX) } +/** + * Any target Orca owns as plumbing, by id because the renderer never sees `owner`: hidden from + * SSH-host surfaces and never dialed by the renderer, which would dispose its owner's session. + */ +export function isOwnedSshTargetId(targetId: string | null | undefined): boolean { + return ( + isRuntimeOwnedSshTargetId(targetId) || + (typeof targetId === 'string' && targetId.startsWith(MACHINE_SSH_TARGET_ID_PREFIX)) + ) +} + export function parseExecutionHostId(value: string | null | undefined): ParsedExecutionHost | null { const normalized = normalizeHostPart(value) if (!normalized) { diff --git a/src/shared/file-write-disk-precondition.test.ts b/src/shared/file-write-disk-precondition.test.ts new file mode 100644 index 000000000000..973f8a34f93f --- /dev/null +++ b/src/shared/file-write-disk-precondition.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + diskBaselineSignatureLength, + getDiskBaselineSignature, + utf8ByteSizeCanDecodeToLength +} from './disk-baseline-signature' +import { + compareDiskWritePrecondition, + diskConflictFromReadFailure, + parseFileWriteResult +} from './file-write-disk-precondition' + +describe('disk write precondition', () => { + it('compares decoded disk text against the baseline signature', () => { + const precondition = { expectedSignature: getDiskBaselineSignature('base') } + expect(compareDiskWritePrecondition(precondition, 'base')).toBeNull() + expect(compareDiskWritePrecondition(precondition, 'newer')).toBe('changed') + expect(compareDiskWritePrecondition(precondition, undefined)).toBe('changed') + expect(compareDiskWritePrecondition(precondition, null)).toBe('missing') + expect(compareDiskWritePrecondition({ ...precondition, allowMissing: true }, null)).toBeNull() + }) + + it('treats anything but an explicit refusal from a host as a completed write', () => { + expect(parseFileWriteResult(undefined)).toEqual({ ok: true }) + expect(parseFileWriteResult({ ok: true })).toEqual({ ok: true }) + expect(parseFileWriteResult({ ok: false, diskConflict: 'missing' })).toEqual({ + ok: false, + diskConflict: 'missing' + }) + expect(parseFileWriteResult({ ok: false })).toEqual({ ok: false, diskConflict: 'changed' }) + }) + + it('bounds the UTF-8 size a baseline can decode from', () => { + const content = 'aโ‚ฌ๐„ž' + const length = diskBaselineSignatureLength(getDiskBaselineSignature(content)) + expect(length).toBe(content.length) + expect(utf8ByteSizeCanDecodeToLength(Buffer.byteLength(content), length ?? -1)).toBe(true) + expect(utf8ByteSizeCanDecodeToLength(content.length - 1, content.length)).toBe(false) + expect(utf8ByteSizeCanDecodeToLength(content.length * 3 + 1, content.length)).toBe(false) + expect(diskBaselineSignatureLength('not-a-signature')).toBeNull() + }) + + it.each([ + [ + 'an ENOENT code with plain wording', + Object.assign(new Error('File not found'), { code: 'ENOENT' }) + ], + ['an SFTP-style message', new Error('No such file or directory')] + ])('treats %s from an older host as a missing file', (_label, error) => { + const precondition = { expectedSignature: getDiskBaselineSignature('base') } + expect(diskConflictFromReadFailure(precondition, error)).toBe('missing') + }) + + it.each([ + [ + 'the desktop IPC wrapping', + "Error invoking remote method 'fs:readFile': Error: ENOENT: no such file or directory, open '/repo/a.ts'" + ], + ['the runtime wording', "ENOENT: no such file or directory, stat '/repo/a.ts'"] + ])('reads a not-found from %s', (_label, message) => { + const precondition = { expectedSignature: getDiskBaselineSignature('base') } + expect(diskConflictFromReadFailure(precondition, new Error(message))).toBe('missing') + }) + + it.each([ + "EACCES: permission denied, open '/repo/ENOENT.md'", + "EACCES: permission denied, open '/repo/ENOENT/notes.md'", + "EACCES: permission denied, open '/repo/No such file or directory/notes.md'", + "EACCES: permission denied, open '/repo/Error: ENOENT: no such file or directory.txt'", + "Error invoking remote method 'fs:readFile': Error: EACCES: permission denied, open '/repo/Error: ENOENT: no such file or directory.txt'" + ])('never reads a path that merely names ENOENT as a missing file: %s', (message) => { + const precondition = { + expectedSignature: getDiskBaselineSignature('base'), + allowMissing: true + } + expect(() => diskConflictFromReadFailure(precondition, new Error(message))).toThrow('EACCES') + }) + + it('trusts a conflicting errno code over not-found wording', () => { + const error = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'EACCES' }) + expect(() => + diskConflictFromReadFailure({ expectedSignature: getDiskBaselineSignature('base') }, error) + ).toThrow('ENOENT') + }) +}) diff --git a/src/shared/file-write-disk-precondition.ts b/src/shared/file-write-disk-precondition.ts new file mode 100644 index 000000000000..caa7f97f0ef9 --- /dev/null +++ b/src/shared/file-write-disk-precondition.ts @@ -0,0 +1,112 @@ +import { getDiskBaselineSignature, getWrittenContentDiskSignature } from './disk-baseline-signature' + +// Why: the host serves `files.writeIfUnchanged`; without it a client compares from its side first. +export const FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY = + 'files.write.disk-precondition.v1' as const + +export const FILE_WRITE_IF_UNCHANGED_METHOD = 'files.writeIfUnchanged' + +/** Refuse the write unless disk still holds the content this signature was taken over. */ +export type FileWriteDiskPrecondition = { + expectedSignature: string + /** A missing file is written (recreated) instead of refused. */ + allowMissing?: boolean +} + +export type FileWriteDiskConflict = 'changed' | 'missing' + +export type FileWriteResult = { ok: true } | { ok: false; diskConflict: FileWriteDiskConflict } + +export const FILE_WRITE_OK: FileWriteResult = { ok: true } + +/** `current` is the decoded disk text, `null` when missing, `undefined` when not comparable text. */ +export function compareDiskWritePrecondition( + precondition: FileWriteDiskPrecondition, + current: string | null | undefined +): FileWriteDiskConflict | null { + if (current === null) { + return precondition.allowMissing === true ? null : 'missing' + } + if (current === undefined) { + return 'changed' + } + return getDiskBaselineSignature(current) === precondition.expectedSignature ? null : 'changed' +} + +/** Why: old hosts answer `{ ok: true }` or nothing; only an explicit `ok: false` is a refusal. */ +export function parseFileWriteResult(value: unknown): FileWriteResult { + if (typeof value !== 'object' || value === null || !('ok' in value) || value.ok !== false) { + return FILE_WRITE_OK + } + const diskConflict = 'diskConflict' in value ? value.diskConflict : undefined + return { ok: false, diskConflict: diskConflict === 'missing' ? 'missing' : 'changed' } +} + +/** Only a not-found answer proves the file is gone; a lost transport proves nothing. */ +function isConfirmedFileNotFoundError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + // Other codes (an RPC envelope's 'runtime_error') carry no errno, so the wording decides. + const code = 'code' in error ? error.code : undefined + if (typeof code === 'string' && ERRNO_CODE.test(code)) { + return code === 'ENOENT' + } + // Why only a leading wrapper is stripped: the rest may quote a path, which can spell anything. + return NOT_FOUND_MESSAGE.test(error.message.replace(IPC_ERROR_WRAPPER, '')) +} + +const ERRNO_CODE = /^E[A-Z0-9]+$/ +const IPC_ERROR_WRAPPER = /^Error invoking remote method '[^']*': (?:Error: )?/ +const NOT_FOUND_MESSAGE = /^(?:ENOENT: no such file or directory|No such file(?: or directory)?$)/i + +// Why both: hosts answer with the `file_too_large` code (carried as the message); local readers in prose. +function isFileTooLargeToReadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = 'code' in error ? error.code : undefined + return ( + code === 'file_too_large' || + error.message === 'file_too_large' || + /\btoo large\b|\bis larger than\b/i.test(error.message) + ) +} + +/** Settles a precondition whose disk read failed; rethrows anything that doesn't prove the disk state. */ +export function diskConflictFromReadFailure( + precondition: FileWriteDiskPrecondition, + error: unknown +): FileWriteDiskConflict | null { + if (isConfirmedFileNotFoundError(error)) { + return compareDiskWritePrecondition(precondition, null) + } + // Why: the baseline was read whole, so content too big to read now cannot match it. + if (isFileTooLargeToReadError(error)) { + return 'changed' + } + throw error +} + +/** For writers that create or restore a file: write only where none exists or it already holds `content`. */ +export function missingOrIdenticalPrecondition(content: string): FileWriteDiskPrecondition { + return { expectedSignature: getWrittenContentDiskSignature(content), allowMissing: true } +} + +export const FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE = + 'A different file already exists at this path, so it was left unchanged.' + +/** A write the host refused before writing anything, so retrying it later is safe. */ +export class FileWriteRefusedError extends Error { + constructor() { + super(FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE) + this.name = 'FileWriteRefusedError' + } +} + +/** Turns a refusal into the error a caller's existing failure path already reports. */ +export function assertFileWriteApplied(result: FileWriteResult): void { + if (!result.ok) { + throw new FileWriteRefusedError() + } +} diff --git a/src/shared/folder-workspace-execution-host.ts b/src/shared/folder-workspace-execution-host.ts index 5e502a6150a8..37c9c136d49c 100644 --- a/src/shared/folder-workspace-execution-host.ts +++ b/src/shared/folder-workspace-execution-host.ts @@ -48,6 +48,7 @@ export type FolderHostCatalog = { projectGroups: readonly (Pick & { parentGroupId: string | null connectionId?: string | null + executionHostId?: string | null })[] repos: readonly R[] } diff --git a/src/shared/imported-folder-project-identity.ts b/src/shared/imported-folder-project-identity.ts new file mode 100644 index 000000000000..b7bf57626c21 --- /dev/null +++ b/src/shared/imported-folder-project-identity.ts @@ -0,0 +1,31 @@ +import { getGitRemoteProviderIdentity } from './project-host-setup-projection' +import type { ProjectProviderIdentity } from './project-types' +import type { Repo } from './repo-types' + +function sameOwnerAndRepo(left: ProjectProviderIdentity, right: ProjectProviderIdentity): boolean { + return ( + left.owner.toLowerCase() === right.owner.toLowerCase() && + left.repo.toLowerCase() === right.repo.toLowerCase() + ) +} + +/** + * Linking stamps the project's identity as the folder's `upstream`, which is only sound when the + * folder's own remote cannot place it (no remote, a non-GitHub remote). A folder whose remote names + * another GitHub repository would be silently relabelled into the wrong project (#30663). + */ +export function assertImportedFolderRemoteMatchesProject( + folder: Pick, + projectIdentity: ProjectProviderIdentity +): void { + const folderIdentity = getGitRemoteProviderIdentity(folder) + // Why owner/repo only: a remote host can be an SSH alias (`github-work`) that resolves to the + // project's host, so a host difference alone proves nothing. + if (!folderIdentity || sameOwnerAndRepo(folderIdentity, projectIdentity)) { + return + } + const remoteName = folder.gitRemoteIdentity?.remoteName ?? 'origin' + throw new Error( + `Imported folder does not match the selected project: its Git remote "${remoteName}" points at ${folderIdentity.owner}/${folderIdentity.repo}, not ${projectIdentity.owner}/${projectIdentity.repo}. Choose the project that matches this folder. If the folder is a fork, add the project's repository as its "upstream" remote; if the repository was renamed, update the folder's remote URL.` + ) +} diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts index 9f220376f353..7964e813f293 100644 --- a/src/shared/managed-orcad-ssh-owner.ts +++ b/src/shared/managed-orcad-ssh-owner.ts @@ -1,10 +1,8 @@ -import type { SshTarget } from './ssh-types' +import type { SshTarget, SshTargetOwner } from './ssh-types' /** The runtime id phase-3 builds wrote into `owner` before the fence moved to `orcadFence`. */ const LEGACY_MANAGED_ORCAD_RUNTIME_ID_PREFIX = 'managed-orcad:' -type SshTargetOwner = NonNullable - /** The managed Orca server this host serves, or null for a direct SSH host. */ export function getManagedOrcadFenceEnvironmentId( target: Pick | undefined diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/shared/markdown-document-paths-windows-client.test.ts similarity index 93% rename from src/main/ipc/markdown-documents-remote-paths.test.ts rename to src/shared/markdown-document-paths-windows-client.test.ts index 0fb41971efe9..3d170a8784a0 100644 --- a/src/main/ipc/markdown-documents-remote-paths.test.ts +++ b/src/shared/markdown-document-paths-windows-client.test.ts @@ -6,10 +6,7 @@ vi.mock('node:path', async (importOriginal) => { return { ...actual, extname: actual.win32.extname } }) -import { - isMarkdownDocumentName, - markdownDocumentFromRelativePath -} from '../../shared/markdown-document-paths' +import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-document-paths' describe('remote Markdown filenames on a Windows client', () => { it('keeps the local filename helper on Windows semantics', () => { diff --git a/src/main/ipc/markdown-documents.test.ts b/src/shared/markdown-document-paths.test.ts similarity index 97% rename from src/main/ipc/markdown-documents.test.ts rename to src/shared/markdown-document-paths.test.ts index 8bc6b12f4c1d..cefcd5b618c8 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/shared/markdown-document-paths.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { markdownDocumentFromFilePath, markdownDocumentFromRelativePath -} from '../../shared/markdown-document-paths' +} from './markdown-document-paths' describe('markdownDocumentFromFilePath', () => { it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => { diff --git a/src/shared/mobile-relay-close-codes.test.ts b/src/shared/mobile-relay-close-codes.test.ts index 6605c30c864f..d218308db83e 100644 --- a/src/shared/mobile-relay-close-codes.test.ts +++ b/src/shared/mobile-relay-close-codes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { isMobileRelayCloseCode, MOBILE_RELAY_CLOSE_CODE, + MOBILE_RELAY_PHONE_CLOSE_REASON, mobileRelayRecoveryFor } from './mobile-relay-close-codes' @@ -19,6 +20,10 @@ describe('mobile relay close-code contract', () => { expect(isMobileRelayCloseCode(1006)).toBe(false) }) + it('locks the phone close reasons the relay mirrors', () => { + expect(MOBILE_RELAY_PHONE_CLOSE_REASON).toEqual({ HOST_ATTACH_TIMEOUT: 'attach-timeout' }) + }) + it('uses the configured director and never a cell-supplied URL', () => { expect(mobileRelayRecoveryFor(4503, 'host-control')).toEqual({ kind: 'resolve-configured-director', diff --git a/src/shared/mobile-relay-close-codes.ts b/src/shared/mobile-relay-close-codes.ts index eaff0a3a735a..2b587449d17e 100644 --- a/src/shared/mobile-relay-close-codes.ts +++ b/src/shared/mobile-relay-close-codes.ts @@ -9,6 +9,15 @@ export const MOBILE_RELAY_CLOSE_CODE = { export type MobileRelayCloseCode = (typeof MOBILE_RELAY_CLOSE_CODE)[keyof typeof MOBILE_RELAY_CLOSE_CODE] + +// Mirror of RELAY_PHONE_CLOSE_REASON in cloud/packages/relay-contract. Close +// reasons on a phone socket that refine its close code; not RELAY_HOST_CLOSE_REASON, +// which names host control-socket closes. Peers that predate one never send it. +export const MOBILE_RELAY_PHONE_CLOSE_REASON = { + // A 4404 sent while the desktop's control looked alive but its data link + // missed the attach deadline; worth one quick redial of the same cell. + HOST_ATTACH_TIMEOUT: 'attach-timeout' +} as const export type MobileRelayLeg = 'host-control' | 'host-data' | 'phone-invite' | 'phone-resume' export type MobileRelayRecovery = diff --git a/src/shared/native-chat-session-option-defaults.test.ts b/src/shared/native-chat-session-option-defaults.test.ts index bcfabe0f71da..e8d3fcd35f5b 100644 --- a/src/shared/native-chat-session-option-defaults.test.ts +++ b/src/shared/native-chat-session-option-defaults.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + applyNativeChatSessionOptionSettingsMutation, clearNativeChatSessionOptionModel, narrowStructuredLaunchSeedOptions, resolveNativeChatSessionOptionDefaults, @@ -185,3 +186,72 @@ describe('narrowStructuredLaunchSeedOptions', () => { expect(narrowStructuredLaunchSeedOptions(values as never)).toBeUndefined() }) }) + +describe('OMP configured thinking default', () => { + const model = 'custom/model' + const persisted: PersistedNativeChatSessionOptions = { + omp: { + model, + valuesByModel: { + [model]: { effort: 'high', serviceTier: 'standard' }, + 'other/model': { effort: 'off' } + } + }, + codex: { model: 'gpt-5.6-sol', valuesByModel: {} } + } + + it.each([undefined, 'other/model'])( + 'removes only the thinking override without adopting its model over %s', + (savedModel) => { + const starting = { ...persisted, omp: { ...persisted.omp, model: savedModel } } + const reset = applyNativeChatSessionOptionSettingsMutation(starting, { + type: 'apply-picks', + agent: 'omp', + picks: [{ modelId: model, optionId: 'effort', value: 'config-default' }] + }) + expect(reset?.omp?.valuesByModel?.[model]).toEqual({ serviceTier: 'standard' }) + expect(reset?.omp?.valuesByModel?.['other/model']).toEqual({ effort: 'off' }) + expect(reset?.codex).toEqual(persisted.codex) + expect(persisted.omp?.valuesByModel?.[model]?.effort).toBe('high') + expect(resolveStructuredLaunchSeedOptions(reset, 'omp')).toEqual( + savedModel ? { model: savedModel, effort: 'off' } : undefined + ) + } + ) + + it('drops a legacy sentinel from OMP saved preferences before encoding startup records', () => { + const legacy = { + omp: { model, valuesByModel: { [model]: { effort: 'config-default' } } } + } + expect(resolveNativeChatSessionOptionDefaults(legacy, 'omp')).toEqual({ model }) + expect(resolveStructuredLaunchSeedOptions(legacy, 'omp')).toEqual({ model }) + }) + + it('preserves another agentโ€™s identically named effort through default resolution and seed encoding', () => { + const selected: PersistedNativeChatSessionOptions = { + grok: { model, valuesByModel: { [model]: { effort: 'config-default' } } } + } + expect(resolveStructuredLaunchSeedOptions(selected, 'grok')).toEqual({ + model, + effort: 'config-default' + }) + }) + + it.each(['off', 'auto'])( + 'retains an explicit native %s preference in startup seeds', + (effort) => { + const selected = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'omp', + modelId: model, + optionId: 'effort', + value: effort + }) + expect(resolveStructuredLaunchSeedOptions(selected, 'omp')).toEqual({ + model, + effort, + serviceTier: 'standard' + }) + } + ) +}) diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index 82b0c0efefa6..c1aa3aa02596 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -6,6 +6,7 @@ import { } from './agent-chat-permission-mode' import type { GlobalSettings } from './global-settings-types' import { sessionOptionValueIsValid } from './agent-session-option-catalog' +import { OMP_CONFIG_DEFAULT_THINKING } from './agent-session-option-catalog-omp' import type { NativeChatSessionOptionSettingsMutation, PersistedNativeChatSessionOptions, @@ -31,6 +32,9 @@ export function resolveNativeChatSessionOptionDefaults( const storedValues = entry?.valuesByModel?.[modelId] if (storedValues && typeof storedValues === 'object') { for (const [id, value] of Object.entries(storedValues)) { + if (agent === 'omp' && id === 'effort' && value === OMP_CONFIG_DEFAULT_THINKING) { + continue + } if (sessionOptionValueIsValid(value)) { values[id] = value } @@ -193,20 +197,27 @@ export function updateNativeChatSessionOptionDefaults(args: { adoptModelAsLaunchDefault?: boolean }): PersistedNativeChatSessionOptions { const currentAgent = args.persisted?.[args.agent] - const currentModelValues = currentAgent?.valuesByModel?.[args.modelId] ?? {} + const currentModelValues = { ...currentAgent?.valuesByModel?.[args.modelId] } + const resetsThinking = + args.agent === 'omp' && args.optionId === 'effort' && args.value === OMP_CONFIG_DEFAULT_THINKING + if (resetsThinking) { + delete currentModelValues.effort + } else if (args.optionId !== 'model') { + currentModelValues[args.optionId] = args.value + } const valuesByModel = { ...currentAgent?.valuesByModel, ...(args.optionId === 'model' ? {} : { - [args.modelId]: { ...currentModelValues, [args.optionId]: args.value } + [args.modelId]: currentModelValues }) } return { ...args.persisted, [args.agent]: { ...currentAgent, - ...(args.adoptModelAsLaunchDefault === false + ...(resetsThinking || args.adoptModelAsLaunchDefault === false ? {} : { model: args.optionId === 'model' ? String(args.value) : args.modelId }), valuesByModel diff --git a/src/shared/omp-model-list-probe.ts b/src/shared/omp-model-list-probe.ts index a71c1dcb301b..0cf0934212a1 100644 --- a/src/shared/omp-model-list-probe.ts +++ b/src/shared/omp-model-list-probe.ts @@ -5,6 +5,48 @@ import { labelFromModelId } from './model-id-label' // default table output groups rows per provider and would need a brittle parser. export const OMP_MODEL_LIST_ARGS = ['models', '--json'] +export const OMP_THINKING_LEVELS = [ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'auto' +] as const + +export function isOmpThinkingLevel(value: string): boolean { + return OMP_THINKING_LEVELS.some((level) => level === value) +} + +/** The listing advertises efforts only; off and auto are OMP session policies. */ +export function ompThinkingLevels( + value: unknown +): NonNullable { + if (!Array.isArray(value)) { + return [] + } + const efforts = new Set( + value.filter( + (level): level is string => + typeof level === 'string' && + isOmpThinkingLevel(level) && + level !== 'off' && + level !== 'auto' + ) + ) + if (efforts.size === 0) { + return [] + } + return OMP_THINKING_LEVELS.filter( + (level) => level === 'off' || level === 'auto' || efforts.has(level) + ).map((id) => ({ + id, + label: id === 'xhigh' ? 'Extra high' : id.charAt(0).toUpperCase() + id.slice(1) + })) +} + /** The outermost JSON value on stdout, or null when none parses. */ function parseJsonObject(stdout: string): unknown { const trimmed = stdout.trim() @@ -66,12 +108,14 @@ export function parseOmpModelList(stdout: string): CommitMessageModel[] { 'contextWindow' in value && typeof value.contextWindow === 'number' ? value.contextWindow : null + const thinkingLevels = ompThinkingLevels('thinking' in value ? value.thinking : undefined) byId.set(id, { id, label: name || labelFromModelId(id), // Why: the same model name ships under several providers; the provider is // what tells two "DeepSeek V4 Pro" rows apart in the picker. ...(provider ? { description: provider } : {}), + ...(thinkingLevels.length ? { thinkingLevels } : {}), ...(contextWindow !== null && Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindowTokens: contextWindow } : {}) diff --git a/src/shared/omp-thinking-generation.test.ts b/src/shared/omp-thinking-generation.test.ts new file mode 100644 index 000000000000..b0fb47ef4cd1 --- /dev/null +++ b/src/shared/omp-thinking-generation.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { planCommitMessageGeneration } from './commit-message-plan' +import { parseOmpModelList } from './omp-model-list-probe' +import { resolveThinkingLevel } from './source-control-ai-model-selection' +import { getDefaultSourceControlAiSettings } from './source-control-ai-settings' + +describe('OMP text generation thinking', () => { + it('retires a saved thinking value when the model no longer advertises it', () => { + const model = parseOmpModelList( + JSON.stringify({ + models: [ + { + provider: 'custom', + id: 'model', + thinking: ['low', 'high'] + } + ] + }) + )[0] + const source = getDefaultSourceControlAiSettings() + source.selectedThinkingByModel[model.id] = 'max' + expect( + resolveThinkingLevel({ + model, + source, + legacy: undefined, + repoOverrides: undefined, + operation: 'commitMessage' + }) + ).toBeUndefined() + source.selectedThinkingByModel[model.id] = 'high' + expect( + resolveThinkingLevel({ + model, + source, + legacy: undefined, + repoOverrides: undefined, + operation: 'commitMessage' + }) + ).toBe('high') + }) + + it.each([ + { saved: undefined, expected: undefined }, + { saved: 'off', expected: 'off' }, + { saved: 'auto', expected: 'auto' }, + { saved: 'low', expected: 'low' }, + { saved: 'max', expected: undefined } + ])('plans only the advertised saved preference $saved', ({ saved, expected }) => { + const model = parseOmpModelList( + JSON.stringify({ models: [{ provider: 'custom', id: 'model', thinking: ['low', 'high'] }] }) + )[0] + const source = getDefaultSourceControlAiSettings() + if (saved !== undefined) { + source.selectedThinkingByModel[model.id] = saved + } + const thinkingLevel = resolveThinkingLevel({ + model, + source, + legacy: undefined, + repoOverrides: undefined, + operation: 'commitMessage' + }) + expect(thinkingLevel).toBe(expected) + const result = planCommitMessageGeneration( + { agentId: 'omp', model: model.id, thinkingLevel }, + 'Generate' + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.plan.args.includes('--thinking')).toBe(expected !== undefined) + if (expected !== undefined) { + expect(result.plan.args[result.plan.args.indexOf('--thinking') + 1]).toBe(expected) + } + } + }) + + it.each(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'auto'])( + 'passes native thinking %s to the actual CLI plan', + (thinkingLevel) => { + const result = planCommitMessageGeneration( + { agentId: 'omp', model: 'custom/model', thinkingLevel }, + 'Generate a commit' + ) + expect(result).toMatchObject({ + ok: true, + plan: { + binary: 'omp', + stdinPayload: 'Generate a commit', + args: expect.arrayContaining(['--model', 'custom/model', '--thinking', thinkingLevel]) + } + }) + if (result.ok) { + expect(result.plan.args.filter((arg) => arg === '--thinking')).toHaveLength(1) + } + } + ) + + it('leaves configured thinking untouched without an explicit selection', () => { + const result = planCommitMessageGeneration( + { agentId: 'omp', model: 'custom/model' }, + 'Generate a branch' + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.plan.args).not.toContain('--thinking') + } + }) + + it.each(['ultra', 'turbo', 'inherit', 'config-default'])( + 'refuses non-native thinking %s', + (thinkingLevel) => { + expect( + planCommitMessageGeneration( + { agentId: 'omp', model: 'custom/model', thinkingLevel }, + 'Generate' + ) + ).toMatchObject({ ok: false }) + } + ) + + it('uses one user override instead of duplicate thinking flags', () => { + const result = planCommitMessageGeneration( + { + agentId: 'omp', + model: 'custom/model', + thinkingLevel: 'max', + agentArgs: '--thinking low' + }, + 'Generate a review' + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.plan.args.filter((arg) => arg === '--thinking')).toHaveLength(1) + expect(result.plan.args[result.plan.args.indexOf('--thinking') + 1]).toBe('low') + expect(result.plan.args).not.toContain('max') + } + }) + + it('preserves unrelated arguments when an equals-form thinking flag overrides preferences', () => { + const result = planCommitMessageGeneration( + { + agentId: 'omp', + model: 'custom/model', + thinkingLevel: 'high', + agentArgs: '--thinking=auto --no-extensions' + }, + 'Generate' + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.plan.args).toContain('--thinking=auto') + expect(result.plan.args).toContain('--no-extensions') + expect(result.plan.args).not.toContain('--thinking') + expect(result.plan.args).not.toContain('high') + } + }) +}) diff --git a/src/shared/orcad-migration-dormant-state-validation.ts b/src/shared/orcad-migration-dormant-state-validation.ts index af054457c463..775cd504067d 100644 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ b/src/shared/orcad-migration-dormant-state-validation.ts @@ -18,6 +18,10 @@ import { assertOrcadMigrationClientStateReferences, parseOrcadMigrationClientState } from './orcad-migration/orcad-migration-client-state' +import { + assertOrcadMigrationEditorDraftsDeclarationReferences, + parseOrcadMigrationEditorDraftsDeclaration +} from './orcad-migration/orcad-migration-editor-drafts' import { assertUnique, boundedArray, @@ -87,7 +91,12 @@ export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationD : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), ...(record.clientState === undefined ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }) + : { clientState: parseOrcadMigrationClientState(record.clientState) }), + ...(record.editorDraftsBlob === undefined + ? {} + : { + editorDraftsBlob: parseOrcadMigrationEditorDraftsDeclaration(record.editorDraftsBlob) + }) } assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') @@ -166,6 +175,12 @@ export function assertOrcadMigrationDormantStateReferences(args: { repositoryIds: args.repositoryIds }) } + if (args.dormantState.editorDraftsBlob) { + assertOrcadMigrationEditorDraftsDeclarationReferences( + args.dormantState.workspaceSession, + args.dormantState.editorDraftsBlob + ) + } assertOrcadMigrationScrollbackReferences( args.dormantState.workspaceSession, args.dormantState.terminalScrollbackSnapshots ?? [] diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts index 92f50bb709a7..c55a9eabd7c4 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.test.ts @@ -3,6 +3,7 @@ import { MAX_ORCAD_MIGRATION_BLOB_TOTAL_BYTES, ORCAD_MIGRATION_BLOB_KIND_CONTRACTS, OrcadMigrationBlobChunkRequestSchema, + OrcadMigrationBlobDescriptorSchema, advertisedOrcadMigrationBlobCapabilities, assertOrcadMigrationBlobSet, findUnsupportedMigrationBlobKind, @@ -123,8 +124,29 @@ describe('orcad migration blob contract', () => { expect(advertisedOrcadMigrationBlobCapabilities()).toEqual([ ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, 'orcad.migration-blob.terminal-restore-hints.v1', - 'orcad.migration-blob.shell-history.v1' + 'orcad.migration-blob.shell-history.v1', + 'orcad.migration-blob.editor-drafts.v1' ]) + // A kind with no installer stays unadvertised, so no desktop sends it here. + expect(advertisedOrcadMigrationBlobCapabilities()).not.toContain( + 'orcad.migration-blob.agent-session-records.v1' + ) + }) + + it('accepts only the one drafts key, up to 64 MiB', () => { + const drafts = (key: string, byteLength: number) => ({ + blobKind: 'editor-drafts', + key, + sha256: 'b'.repeat(64), + byteLength + }) + expect( + OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts', 64 * 1024 * 1024)).success + ).toBe(true) + expect(OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts-2', 4)).success).toBe(false) + expect( + OrcadMigrationBlobDescriptorSchema.safeParse(drafts('drafts', 64 * 1024 * 1024 + 1)).success + ).toBe(false) }) it('keeps unknown request fields out and checks the acknowledged length', () => { diff --git a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts index a098939c081b..f32e325be26c 100644 --- a/src/shared/orcad-migration/orcad-migration-blob-transfer.ts +++ b/src/shared/orcad-migration/orcad-migration-blob-transfer.ts @@ -19,7 +19,8 @@ import { ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY } from '../orcad-runtim export const ORCAD_MIGRATION_BLOB_KINDS = [ 'terminal-restore-hints', 'shell-history', - 'agent-session-records' + 'agent-session-records', + 'editor-drafts' ] as const export type OrcadMigrationBlobKind = (typeof ORCAD_MIGRATION_BLOB_KINDS)[number] @@ -54,6 +55,12 @@ export const ORCAD_MIGRATION_BLOB_KIND_CONTRACTS: Record< key: /^[a-z0-9][a-z0-9_-]{0,127}$/, maxBytes: 64 * MIB, capability: 'orcad.migration-blob.agent-session-records.v1' + }, + // Every shed unsaved editor buffer of the migration in one document, named by the manifest. + 'editor-drafts': { + key: /^drafts$/, + maxBytes: 64 * MIB, + capability: 'orcad.migration-blob.editor-drafts.v1' } } @@ -259,7 +266,8 @@ export function parseOrcadMigrationBlobChunkResult( /** Kinds this build's destination installs after commit; each is added with its installer. */ export const ORCAD_MIGRATION_INSTALLABLE_BLOB_KINDS: readonly OrcadMigrationBlobKind[] = [ 'terminal-restore-hints', - 'shell-history' + 'shell-history', + 'editor-drafts' ] export function advertisedOrcadMigrationBlobCapabilities(): string[] { diff --git a/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts b/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts new file mode 100644 index 000000000000..db5bd38878d1 --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-editor-drafts.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../constants' +import type { PersistedOpenFile } from '../workspace-session-state-types' +import { + parseOrcadMigrationEditorDraftsBytes, + type OrcadMigrationEditorDraftsDeclaration +} from './orcad-migration-editor-drafts' +import { serializeOrcadMigrationValue } from './orcad-migration-manifest' +import { + ORCAD_MIGRATION_MANIFEST_VERSION, + parseOrcadMigrationManifest +} from './orcad-migration-manifest-validation' + +const WORKTREE = 'repo-1::/srv/repo-1-wt' +const FILE_A = '/srv/repo-1-wt/a.ts' +const FILE_B = '/srv/repo-1-wt/b.ts' + +function file(filePath: string, dirtyDraftContent?: string): PersistedOpenFile { + return { + filePath, + relativePath: filePath.slice('/srv/repo-1-wt/'.length), + worktreeId: WORKTREE, + language: 'typescript', + ...(dirtyDraftContent === undefined ? {} : { dirtyDraftContent }) + } +} + +const declaration: OrcadMigrationEditorDraftsDeclaration = { + sha256: 'c'.repeat(64), + byteLength: 120, + entries: [{ worktreeKey: WORKTREE, filePath: FILE_A }] +} + +function rawManifest(editorDraftsBlob: unknown, files = [file(FILE_A), file(FILE_B, 'inline')]) { + return { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId: 'migration-drafts', + createdAt: '2026-10-01T12:00:00.000Z', + source: { sshTargetId: 'target', sshTargetGeneration: 3, targetLabel: 'Host' }, + payload: { + repositories: [ + { + id: 'repo-1', + path: '/srv/repo-1', + displayName: 'Repo', + badgeColor: '#737373', + addedAt: 1 + } + ], + projectGroups: [], + folderWorkspaces: [], + dormantState: { + version: 1, + worktreeMeta: [], + worktreeLineage: [], + workspaceLineage: [], + sparsePresets: [], + retiredWorktreeNames: [], + retiredWorktreeNamespaces: [], + workspaceSession: { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [WORKTREE]: files } + }, + ...(editorDraftsBlob === undefined ? {} : { editorDraftsBlob }) + } + }, + manifestSha256: 'a'.repeat(64) + } +} + +describe('orcad migration editor drafts', () => { + it('keeps the signed declaration in its golden wire shape', () => { + const parsed = parseOrcadMigrationManifest(rawManifest(declaration)) + expect( + serializeOrcadMigrationValue(parsed.payload.dormantState?.editorDraftsBlob) + ).toMatchInlineSnapshot( + `"{"byteLength":120,"entries":[{"filePath":"/srv/repo-1-wt/a.ts","worktreeKey":"repo-1::/srv/repo-1-wt"}],"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}"` + ) + }) + + it('stays optional: a manifest without it parses as before', () => { + const parsed = parseOrcadMigrationManifest(rawManifest(undefined)) + expect(parsed.payload.dormantState).not.toHaveProperty('editorDraftsBlob') + }) + + it('names only shed editor entries, each exactly once', () => { + const scope = 'orcad_migration_editor_drafts_declaration_scope_invalid' + const entry = (filePath: string, worktreeKey = WORKTREE) => ({ + ...declaration, + entries: [{ worktreeKey, filePath }] + }) + // An entry that still carries its draft inline. + expect(() => parseOrcadMigrationManifest(rawManifest(entry(FILE_B)))).toThrow(scope) + expect(() => parseOrcadMigrationManifest(rawManifest(entry('/srv/repo-1-wt/none.ts')))).toThrow( + scope + ) + expect(() => + parseOrcadMigrationManifest(rawManifest(entry(FILE_A, 'repo-1::/srv/other'))) + ).toThrow(scope) + expect(() => + parseOrcadMigrationManifest(rawManifest(declaration, [file(FILE_A), file(FILE_A)])) + ).toThrow(scope) + expect(() => + parseOrcadMigrationManifest( + rawManifest({ ...declaration, entries: [...declaration.entries, ...declaration.entries] }) + ) + ).toThrow('orcad_migration_dormant_editor_drafts_entry_duplicate') + }) + + it('refuses a malformed declaration', () => { + const invalid = 'orcad_migration_editor_drafts_declaration_invalid' + for (const value of [ + { ...declaration, entries: [] }, + { ...declaration, sha256: 'not-a-digest' }, + { ...declaration, byteLength: 0 }, + { ...declaration, byteLength: 64 * 1024 * 1024 + 1 }, + { ...declaration, entries: [{ worktreeKey: WORKTREE, filePath: '' }] } + ]) { + expect(() => parseOrcadMigrationManifest(rawManifest(value))).toThrow(invalid) + } + }) + + it('maps the blob one-to-one onto the declared entries', () => { + const two: OrcadMigrationEditorDraftsDeclaration = { + ...declaration, + entries: [ + { worktreeKey: WORKTREE, filePath: FILE_A }, + { worktreeKey: WORKTREE, filePath: FILE_B } + ] + } + const bytes = (drafts: unknown[], version = 1) => + Buffer.from(JSON.stringify({ version, drafts })) + const a = { worktreeKey: WORKTREE, filePath: FILE_A, content: '' } + const b = { worktreeKey: WORKTREE, filePath: FILE_B, content: 'b' } + + expect(parseOrcadMigrationEditorDraftsBytes(bytes([b, a]), two)).toEqual([b, a]) + expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a]), two)).toThrow( + 'orcad_migration_editor_drafts_set_mismatch' + ) + expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a, b]), declaration)).toThrow( + 'orcad_migration_editor_drafts_set_mismatch' + ) + expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a, a]), two)).toThrow( + 'orcad_migration_dormant_editor_drafts_entry_duplicate' + ) + expect(() => parseOrcadMigrationEditorDraftsBytes(bytes([a], 2), declaration)).toThrow( + 'orcad_migration_editor_drafts_invalid' + ) + expect(() => + parseOrcadMigrationEditorDraftsBytes(Buffer.from([0xff, 0xfe]), declaration) + ).toThrow('orcad_migration_editor_drafts_not_json') + }) +}) diff --git a/src/shared/orcad-migration/orcad-migration-editor-drafts.ts b/src/shared/orcad-migration/orcad-migration-editor-drafts.ts new file mode 100644 index 000000000000..c829a85007f3 --- /dev/null +++ b/src/shared/orcad-migration/orcad-migration-editor-drafts.ts @@ -0,0 +1,132 @@ +/** + * The `editor-drafts` blob: unsaved editor buffers shed from the signed manifest so it stays under + * its size cap. The manifest declares the blob's digest and exactly which editor entries it fills + * (`dormantState.editorDraftsBlob`); those entries carry no inline `dirtyDraftContent`. + */ +import { z } from 'zod' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from './orcad-migration-blob-transfer' +import { + MAX_ORCAD_MIGRATION_DORMANT_ROWS, + assertUnique, + requiredRecord, + requiredString +} from './orcad-migration-dormant-value-validation' +import { OrcadMigrationDigestSchema } from './orcad-migration-scrollback' + +export const ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION = 1 +export const ORCAD_MIGRATION_EDITOR_DRAFTS_KEY = 'drafts' +export const MAX_ORCAD_MIGRATION_EDITOR_DRAFTS_BYTES = + ORCAD_MIGRATION_BLOB_KIND_CONTRACTS['editor-drafts'].maxBytes + +/** One `openFilesByWorktree[worktreeKey]` entry, by its `filePath`. */ +export type OrcadMigrationEditorDraftEntry = { worktreeKey: string; filePath: string } + +export type OrcadMigrationEditorDraftsDeclaration = { + sha256: string + byteLength: number + entries: OrcadMigrationEditorDraftEntry[] +} + +export type OrcadMigrationEditorDraft = OrcadMigrationEditorDraftEntry & { content: string } + +export type OrcadMigrationEditorDraftsDocument = { + version: typeof ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION + drafts: OrcadMigrationEditorDraft[] +} + +export function orcadMigrationEditorDraftIdentity(entry: OrcadMigrationEditorDraftEntry): string { + return `${entry.worktreeKey}\0${entry.filePath}` +} + +export function parseOrcadMigrationEditorDraftsDeclaration( + value: unknown +): OrcadMigrationEditorDraftsDeclaration { + const record = requiredRecord(value, 'orcad_migration_editor_drafts_declaration_invalid') + const sha256 = OrcadMigrationDigestSchema.safeParse(record.sha256) + if ( + !sha256.success || + !Number.isSafeInteger(record.byteLength) || + Number(record.byteLength) < 1 || + Number(record.byteLength) > MAX_ORCAD_MIGRATION_EDITOR_DRAFTS_BYTES || + !Array.isArray(record.entries) || + record.entries.length === 0 || + record.entries.length > MAX_ORCAD_MIGRATION_DORMANT_ROWS + ) { + throw new Error('orcad_migration_editor_drafts_declaration_invalid') + } + const entries = record.entries.map((entry) => { + const item = requiredRecord(entry, 'orcad_migration_editor_drafts_declaration_invalid') + return { + worktreeKey: requiredString( + item.worktreeKey, + 'orcad_migration_editor_drafts_declaration_invalid' + ), + filePath: requiredString(item.filePath, 'orcad_migration_editor_drafts_declaration_invalid') + } + }) + assertUnique(entries, orcadMigrationEditorDraftIdentity, 'editor_drafts_entry') + return { sha256: sha256.data, byteLength: Number(record.byteLength), entries } +} + +/** Each declared entry names exactly one session editor entry, and that entry has no inline draft. */ +export function assertOrcadMigrationEditorDraftsDeclarationReferences( + session: WorkspaceSessionState | undefined, + declaration: OrcadMigrationEditorDraftsDeclaration +): void { + for (const entry of declaration.entries) { + const matches = (session?.openFilesByWorktree?.[entry.worktreeKey] ?? []).filter( + (file) => file.filePath === entry.filePath + ) + if (matches.length !== 1 || matches[0].dirtyDraftContent !== undefined) { + throw new Error('orcad_migration_editor_drafts_declaration_scope_invalid') + } + } +} + +// Not strict: a same-version sender may add optional fields this build ignores. +const EditorDraftsDocumentSchema = z.object({ + version: z.literal(ORCAD_MIGRATION_EDITOR_DRAFTS_VERSION), + drafts: z + .array( + z.object({ + worktreeKey: z.string().min(1), + filePath: z.string().min(1), + content: z.string() + }) + ) + .max(MAX_ORCAD_MIGRATION_DORMANT_ROWS) +}) + +const UTF8 = new TextDecoder('utf-8', { fatal: true }) + +/** The blob's bytes, held to an exact one-to-one match with the declared entries. */ +export function parseOrcadMigrationEditorDraftsBytes( + bytes: Uint8Array, + declaration: OrcadMigrationEditorDraftsDeclaration +): OrcadMigrationEditorDraft[] { + let value: unknown + try { + value = JSON.parse(UTF8.decode(bytes)) + } catch { + throw new Error('orcad_migration_editor_drafts_not_json') + } + const parsed = EditorDraftsDocumentSchema.safeParse(value) + if (!parsed.success) { + throw new Error('orcad_migration_editor_drafts_invalid') + } + const drafts = parsed.data.drafts.map((draft) => ({ + worktreeKey: draft.worktreeKey, + filePath: draft.filePath, + content: draft.content + })) + assertUnique(drafts, orcadMigrationEditorDraftIdentity, 'editor_drafts_entry') + const declared = new Set(declaration.entries.map(orcadMigrationEditorDraftIdentity)) + if ( + drafts.length !== declared.size || + drafts.some((draft) => !declared.has(orcadMigrationEditorDraftIdentity(draft))) + ) { + throw new Error('orcad_migration_editor_drafts_set_mismatch') + } + return drafts +} diff --git a/src/shared/orcad-migration/orcad-migration-manifest.ts b/src/shared/orcad-migration/orcad-migration-manifest.ts index 0de03f92e1a4..11d57fcaebe0 100644 --- a/src/shared/orcad-migration/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration/orcad-migration-manifest.ts @@ -14,6 +14,7 @@ import type { OrcadMigrationTerminalScrollbackSnapshot } from './orcad-migration-scrollback' import type { OrcadMigrationBlobUploadState } from './orcad-migration-blob-transfer' +import type { OrcadMigrationEditorDraftsDeclaration } from './orcad-migration-editor-drafts' import { isRecord } from './orcad-migration-manifest-fields' @@ -92,6 +93,11 @@ export type OrcadMigrationDormantStatePayload = { automationRuns?: AutomationRun[] /** Client-owned durable intent projected to the destination authority. */ clientState?: OrcadMigrationClientStatePayload + /** + * The `editor-drafts` blob holding the named entries' unsaved buffers. Only sent to a host that + * advertises that kind: an older parser drops this field and would commit without the drafts. + */ + editorDraftsBlob?: OrcadMigrationEditorDraftsDeclaration } export type OrcadMigrationManifest = { diff --git a/src/shared/plugins/plugin-manifest.ts b/src/shared/plugins/plugin-manifest.ts index aa7bae64fc44..8115006f4159 100644 --- a/src/shared/plugins/plugin-manifest.ts +++ b/src/shared/plugins/plugin-manifest.ts @@ -73,62 +73,54 @@ const eventContributionSchema = z.object({ on: z.enum(PLUGIN_EVENT_NAMES) }) -export const pluginManifestSchema = z - .object({ - manifestVersion: z.literal(1), - id: pluginIdSchema, - /** Publisher slug; canonical identity is `.` โ€” bare-id - * global uniqueness is unverifiable without a registry. */ - publisher: pluginIdSchema, - name: z.string().min(1).max(256), - version: z.string().regex(SEMVER_RE, 'must be semver'), - description: z.string().max(4096).optional(), - author: z - .object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() }) - .optional(), - repository: z.string().max(2048).optional(), - icon: pluginRelativePathSchema.optional(), - /** Minimum host version gate; the host refuses to load below it. */ - engines: z.object({ orca: orcaEngineRangeSchema }), - /** Host-API major version this plugin targets. */ - pluginApi: z.literal(1), - /** Node entry executed inside the out-of-process plugin worker. */ - main: pluginRelativePathSchema.optional(), - contributes: z - .object({ - panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]), - commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]), - events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]), - languagePacks: z - .array(pluginLanguagePackContributionSchema) - .max(PLUGIN_LANGUAGE_PACK_LIMIT) - .default([]), - keybindings: z - .array(pluginKeybindingContributionSchema) - .max(PLUGIN_KEYBINDING_LIMIT) - .default([]), - vmRecipes: z - .array(pluginVmRecipeContributionSchema) - .max(PLUGIN_VM_RECIPE_LIMIT) - .default([]), - agents: z - .array(pluginAgentProfileContributionSchema) - .max(PLUGIN_AGENT_PROFILE_LIMIT) - .default([]) - }) - .strict() - .default(() => ({ - panels: [], - commands: [], - events: [], - languagePacks: [], - keybindings: [], - vmRecipes: [], - agents: [] - })), - capabilities: z.array(pluginCapabilitySchema).max(32).default([]) - }) - .superRefine(validatePluginManifestContributions) +const pluginManifestV1Object = z.object({ + manifestVersion: z.literal(1), + id: pluginIdSchema, + /** Publisher slug; canonical identity is `.` โ€” bare-id + * global uniqueness is unverifiable without a registry. */ + publisher: pluginIdSchema, + name: z.string().min(1).max(256), + version: z.string().regex(SEMVER_RE, 'must be semver'), + description: z.string().max(4096).optional(), + author: z + .object({ name: z.string().min(1).max(256), url: z.string().max(2048).optional() }) + .optional(), + repository: z.string().max(2048).optional(), + icon: pluginRelativePathSchema.optional(), + /** Minimum host version gate; the host refuses to load below it. */ + engines: z.object({ orca: orcaEngineRangeSchema }), + /** Host-API major version this plugin targets. */ + pluginApi: z.literal(1), + /** Node entry executed inside the out-of-process plugin worker. */ + main: pluginRelativePathSchema.optional(), + contributes: z + .object({ + panels: z.array(panelContributionSchema).max(PLUGIN_PANEL_LIMIT).default([]), + commands: z.array(commandContributionSchema).max(PLUGIN_COMMAND_LIMIT).default([]), + events: z.array(eventContributionSchema).max(PLUGIN_EVENT_SUBSCRIPTION_LIMIT).default([]), + languagePacks: z + .array(pluginLanguagePackContributionSchema) + .max(PLUGIN_LANGUAGE_PACK_LIMIT) + .default([]), + keybindings: z + .array(pluginKeybindingContributionSchema) + .max(PLUGIN_KEYBINDING_LIMIT) + .default([]), + vmRecipes: z.array(pluginVmRecipeContributionSchema).max(PLUGIN_VM_RECIPE_LIMIT).default([]), + agents: z + .array(pluginAgentProfileContributionSchema) + .max(PLUGIN_AGENT_PROFILE_LIMIT) + .default([]) + }) + .strict() + // Why prefault: runs `{}` through the schema, so every family's own default applies. + .prefault({}), + capabilities: z.array(pluginCapabilitySchema).max(32).default([]) +}) + +export const pluginManifestSchema = pluginManifestV1Object.superRefine( + validatePluginManifestContributions +) export type PluginManifest = z.infer export type PluginPanelContribution = z.infer diff --git a/src/shared/plugins/plugin-marketplace.ts b/src/shared/plugins/plugin-marketplace.ts index 98d7eb8059b8..3fc2ea732c65 100644 --- a/src/shared/plugins/plugin-marketplace.ts +++ b/src/shared/plugins/plugin-marketplace.ts @@ -53,50 +53,63 @@ export const pluginMarketplaceGitSourceSchema = z.strictObject({ ref: z.string().trim().min(1).max(4_096) }) -export const pluginMarketplaceEntrySchema = z - .strictObject({ - /** Canonical `.` identity expected in the source manifest. */ - id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), - source: pluginMarketplaceGitSourceSchema, - description: z.string().min(1).max(4_096).optional(), - categories: z - .array(marketplaceCategorySchema) - .max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT) - .default([]) - }) - .superRefine((entry, context) => { - const seen = new Set() - for (const [index, category] of entry.categories.entries()) { - if (seen.has(category)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['categories', index], - message: `duplicate category: ${category}` - }) - } - seen.add(category) +const marketplaceEntryFields = { + /** Canonical `.` identity expected in the source manifest. */ + id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + source: pluginMarketplaceGitSourceSchema, + description: z.string().min(1).max(4_096).optional(), + categories: z.array(marketplaceCategorySchema).max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT).default([]) +} + +function rejectDuplicateCategories( + entry: { categories: string[] }, + context: z.RefinementCtx +): void { + const seen = new Set() + for (const [index, category] of entry.categories.entries()) { + if (seen.has(category)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['categories', index], + message: `duplicate category: ${category}` + }) } - }) + seen.add(category) + } +} + +function rejectDuplicatePluginIds( + marketplace: { plugins: { id: string }[] }, + context: z.RefinementCtx +): void { + const seen = new Set() + for (const [index, plugin] of marketplace.plugins.entries()) { + if (seen.has(plugin.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['plugins', index, 'id'], + message: `duplicate plugin id: ${plugin.id}` + }) + } + seen.add(plugin.id) + } +} + +export const pluginMarketplaceEntrySchema = z + .strictObject(marketplaceEntryFields) + .superRefine(rejectDuplicateCategories) + +const marketplaceHeaderFields = { + name: z.string().min(1).max(256), + owner: marketplaceOwnerSchema +} export const pluginMarketplaceSchema = z .strictObject({ - name: z.string().min(1).max(256), - owner: marketplaceOwnerSchema, + ...marketplaceHeaderFields, plugins: z.array(pluginMarketplaceEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT) }) - .superRefine((marketplace, context) => { - const seen = new Set() - for (const [index, plugin] of marketplace.plugins.entries()) { - if (seen.has(plugin.id)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - path: ['plugins', index, 'id'], - message: `duplicate plugin id: ${plugin.id}` - }) - } - seen.add(plugin.id) - } - }) + .superRefine(rejectDuplicatePluginIds) /** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */ export const pluginMarketplaceTrustMetadataSchema = z diff --git a/src/shared/process-table-index.ts b/src/shared/process-table-index.ts index e8dc308b80bd..8675bd04ded1 100644 --- a/src/shared/process-table-index.ts +++ b/src/shared/process-table-index.ts @@ -67,14 +67,16 @@ export function buildProcessTableIndex( */ export function collectDescendantsFromIndex( index: ProcessTableIndexOf, - rootPid: number + rootPid: number, + /** Excludes a row and everything below it. */ + prune?: (row: Row) => boolean ): (Row & { depth: number })[] { const descendants: (Row & { depth: number })[] = [] // PID reuse can make a process snapshot cyclic; queue each PID at most once. const visited = new Set([rootPid]) const stack: { row: Row; depth: number }[] = [] const pushUnvisited = (row: Row, depth: number): void => { - if (visited.has(row.pid)) { + if (visited.has(row.pid) || prune?.(row)) { return } visited.add(row.pid) diff --git a/src/shared/program-status/agent-status-projector.ts b/src/shared/program-status/agent-status-projector.ts index b01fab8853c4..684791fc89cc 100644 --- a/src/shared/program-status/agent-status-projector.ts +++ b/src/shared/program-status/agent-status-projector.ts @@ -1,10 +1,7 @@ // Projects OSC 7501 records onto agent-status payloads so they ride the OSC 9999 terminal-status ingress. import type { AgentStatusState, ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status-subagent-snapshot' -import { - AGENT_STATUS_MAX_SUBAGENTS, - AGENT_SUBAGENT_ID_MAX_LENGTH -} from '../agent-status-subagent-snapshot' +import { AGENT_SUBAGENT_ID_MAX_LENGTH } from '../agent-status-subagent-snapshot' import type { AgentTurnOutcome } from '../agent-turn-outcome' import { createProgramStatusOscScanner } from './osc-scanner' import { @@ -160,7 +157,7 @@ export function createProgramStatusProjector(now: () => number = Date.now): Prog childStartedAt.delete(id) } } - return children.slice(-AGENT_STATUS_MAX_SUBAGENTS) + return children } const project = (): ParsedAgentStatusPayload | null => { diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 1c4d6f06c4ba..b87d27f5319d 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -59,6 +59,13 @@ export function getProjectProviderIdentity( } } } + return getGitRemoteProviderIdentity(repo) +} + +/** The GitHub identity the folder's own primary remote names, ignoring stamped `upstream`. */ +export function getGitRemoteProviderIdentity( + repo: Pick +): ProjectProviderIdentity | null { // Why: the remote URL retains HTTP(S) endpoint ports that the canonical // key omits, so prefer it when reconstructing a host-qualified GHES identity. return ( @@ -197,7 +204,8 @@ function parseGitHubRemoteUrl(remoteUrl: string | undefined): ProjectProviderIde if (!trimmed) { return null } - const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) + // Why: SSH certificate orgs use `org-@github.com:/`, so the user is not always `git`. + const sshMatch = trimmed.match(/^[^@\s/:]+@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i) if (sshMatch?.[1] && sshMatch[2] && sshMatch[3]) { return projectProviderIdentity(sshMatch[1], sshMatch[2], sshMatch[3]) } diff --git a/src/shared/project-host-setup-target.test.ts b/src/shared/project-host-setup-target.test.ts new file mode 100644 index 000000000000..dfc7a89f8b88 --- /dev/null +++ b/src/shared/project-host-setup-target.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { findProjectHostSetup } from './project-host-setup-target' + +const local = { id: 'repo-1', hostId: 'local' as const, repoId: 'repo-1' } +const ssh = { id: 'repo-1', hostId: 'ssh:box' as const, repoId: 'repo-1' } + +describe('findProjectHostSetup', () => { + it('keeps the first match by id when the caller names no host', () => { + expect(findProjectHostSetup([local, ssh], { setupId: 'repo-1' })).toBe(local) + }) + + it('picks the named twin', () => { + expect( + findProjectHostSetup([local, ssh], { + setupId: 'repo-1', + hostId: 'ssh:box' + }) + ).toBe(ssh) + }) + + it('never falls back to a sibling when the named twin is gone', () => { + expect(findProjectHostSetup([local], { setupId: 'repo-1', hostId: 'ssh:box' })).toBeUndefined() + }) + + it('refuses a wrongly named host for an independent setup', () => { + const provisioned = { id: 'p::gpu', hostId: 'local' as const, repoId: '' } + expect( + findProjectHostSetup([provisioned], { setupId: 'p::gpu', hostId: 'ssh:other' }) + ).toBeUndefined() + }) +}) diff --git a/src/shared/project-host-setup-target.ts b/src/shared/project-host-setup-target.ts new file mode 100644 index 000000000000..0c19659bd33c --- /dev/null +++ b/src/shared/project-host-setup-target.ts @@ -0,0 +1,22 @@ +import type { ExecutionHostId } from './execution-host' + +/** + * Names one setup. Repo-backed setups take the repo id, so twins on different hosts share + * `setupId` and `hostId` picks the host; older clients omit it and keep the first match by id. + */ +export type ProjectHostSetupTarget = { + setupId: string + hostId?: ExecutionHostId +} + +type SetupIdentity = { id: string; hostId: string } + +export function findProjectHostSetup( + setups: readonly T[], + target: ProjectHostSetupTarget +): T | undefined { + return setups.find( + (setup) => + setup.id === target.setupId && (target.hostId === undefined || setup.hostId === target.hostId) + ) +} diff --git a/src/shared/project-types.ts b/src/shared/project-types.ts index 4ef6e522c850..379fa42f89d8 100644 --- a/src/shared/project-types.ts +++ b/src/shared/project-types.ts @@ -5,6 +5,7 @@ import type { LocalWindowsRuntimePreference } from './project-execution-runtime' import type { RepoHookSettings } from './orca-yaml-hook-types' import type { RepoSourceControlAiOverrides } from './source-control-ai-types' import type { Repo, RepoKind } from './repo-types' +import type { ProjectHostSetupTarget } from './project-host-setup-target' export type ProjectProviderIdentity = { provider: 'github' @@ -98,8 +99,7 @@ export type ProjectHostSetupCloneArgs = { displayName?: string } -export type ProjectHostSetupUpdateArgs = { - setupId: string +export type ProjectHostSetupUpdateArgs = ProjectHostSetupTarget & { updates: Partial< Pick< ProjectHostSetup, @@ -114,8 +114,7 @@ export type ProjectHostSetupUpdateArgs = { > } -export type ProjectHostSetupDeleteArgs = { - setupId: string +export type ProjectHostSetupDeleteArgs = ProjectHostSetupTarget & { /** `false` refuses while the project has live terminals or workspace metadata; absent skips the check. */ force?: boolean } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2c79ff6a5675..1decc304744b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -27,7 +27,8 @@ export { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities' import { AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES } from './agent-session-attention-capabilities' export { @@ -59,6 +60,7 @@ import { AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY } from './agent-sessi import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' import { TERMINAL_QUICK_COMMAND_RUNTIME_CAPABILITIES } from './terminal-quick-command-capabilities' +import { FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY } from './file-write-disk-precondition' // Why a star: every resume capability is part of the runtime contract this module re-exports. export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' @@ -111,6 +113,7 @@ import { NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY, PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY, PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, @@ -190,6 +193,7 @@ export const RUNTIME_CAPABILITIES = [ ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, 'files.pathsExist', 'files.resolve-terminal-path.cross-workspace.v1', + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, 'runtime.status.compat.v1', 'runtime.environments.v1', REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, @@ -208,6 +212,7 @@ export const RUNTIME_CAPABILITIES = [ 'workspace-ports.v1', 'mobile.tasks.v1', PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY, TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 612bf0bfb5df..f7dbfaace91c 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -15,6 +15,8 @@ import { import { TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY } from './terminal-quick-command-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability' +import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from './sidebar-inputs-switch' +import { WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY } from './workspace-unread-raise-capability' // Keep every remote request transport on the same mixed-version capability contract. export function remoteRuntimeClientCapabilities( @@ -36,6 +38,9 @@ export function remoteRuntimeClientCapabilities( AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY, + // Switch point: every desktop socket, browser hosting's included, leaves the unread flag to + // a host that raises it. + ...(ORCA_SIDEBAR_INPUTS_IS_MAIN ? [WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY] : []), ...additionalCapabilities ]) ) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 759014aa604f..6491586ba89e 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -10,13 +10,6 @@ import type { import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from './runtime-rpc-envelope' import type { RuntimeStatus } from './runtime-types' -export { RemoteRuntimeClientError } from './remote-runtime-client-error' -export type { - RemoteRuntimeOutboundMemoryBudget, - RemoteRuntimeOutboundSocketMemory -} from './remote-runtime-subscription-outbound' -export type { RemoteRuntimeSubscriptionOptions } from './remote-runtime-subscription-contract' - export type RemoteRuntimeSubscription = { requestId: string close: () => void diff --git a/src/shared/remote-runtime-request-websocket.ts b/src/shared/remote-runtime-request-websocket.ts index 13df27a6500f..02b243cca362 100644 --- a/src/shared/remote-runtime-request-websocket.ts +++ b/src/shared/remote-runtime-request-websocket.ts @@ -2,7 +2,7 @@ import WebSocket from 'ws' import type { PairedRuntimeConnection } from './paired-runtime-connection' import type { PairedE2EEClientHandshake } from './paired-e2ee-client-handshake' import { createRemoteRuntimeClientHandshake } from './remote-runtime-client-handshake' -import { RemoteRuntimeClientError } from './remote-runtime-client' +import { RemoteRuntimeClientError } from './remote-runtime-client-error' import { remoteRuntimeConnectFailureMessage, remoteRuntimeConnectOptions diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 5baa3af9256a..5c84d81231da 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -38,7 +38,8 @@ export class RemoteRuntimeSharedControlConnection { private readonly retiredRequestIds = new SharedControlRetiredRequestIds() private readonly readyWaiters: SharedControlTypes.SharedControlReadyWaiter[] = [] private everReady = false - private readonly socketGeneration = new SharedControlSocketGeneration() + /** Changes whenever the socket does, so two calls can prove they shared one socket. */ + readonly socketGeneration = new SharedControlSocketGeneration() constructor( private readonly connection: PairedRuntimeConnection, @@ -54,9 +55,11 @@ export class RemoteRuntimeSharedControlConnection { params: unknown, timeoutMs: number, envelope?: Parameters[0]['envelope'], - signal?: AbortSignal + signal?: AbortSignal, + beforeSend?: () => void ): ReturnType> { return requestSharedControl({ + beforeSend, pendingRequests: this.pendingRequests, deviceToken: this.connection.credential.deviceToken, method, @@ -120,22 +123,19 @@ export class RemoteRuntimeSharedControlConnection { } private publishDiagnostics(): void { - this.diagnostics.publish({ - state: this.state, - reconnecting: this.reconnect.isScheduled, - pendingRequestCount: this.pendingRequests.size, - subscriptionCount: this.subscriptions.size, - reconnectAttempt: this.reconnect.attemptCount - }) + this.diagnostics.publish(this.diagnosticsCounts()) } getDiagnostics(): SharedControlTypes.RemoteRuntimeSharedConnectionDiagnostics { - return this.diagnostics.get({ + return this.diagnostics.get(this.diagnosticsCounts()) + } + private diagnosticsCounts() { + return { state: this.state, reconnecting: this.reconnect.isScheduled, pendingRequestCount: this.pendingRequests.size, subscriptionCount: this.subscriptions.size, reconnectAttempt: this.reconnect.attemptCount - }) + } } reconnectNow(): void { refreshRemoteRuntimeSharedControl({ diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index 73b158737676..347c8b4204cf 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -1,6 +1,6 @@ import type { PairedE2EEClientHandshake } from './paired-e2ee-client-handshake' import type WebSocket from 'ws' -import { RemoteRuntimeClientError } from './remote-runtime-client' +import { RemoteRuntimeClientError } from './remote-runtime-client-error' import { serializeRemoteRuntimePayload } from './remote-runtime-memory-limits' import { invalidRemoteRuntimeResponseError, diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 84753d1683be..32cfe83f01cc 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -28,6 +28,8 @@ export function requestSharedControl(args: { // Why: default off โ€” ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean + /** Runs once the socket is ready, just before sending; a throw rejects without sending. */ + beforeSend?: () => void }): Promise> { const { ensureReady, pendingRequests, send } = args if (args.signal?.aborted) { @@ -93,7 +95,19 @@ export function requestSharedControl(args: { return } void ensureReady().then( - () => send(requestId), + () => { + try { + args.beforeSend?.() + } catch (error) { + rejectSharedControlPendingRequest( + pendingRequests, + requestId, + toRemoteRuntimeClientError(error) + ) + return + } + send(requestId) + }, (error) => rejectSharedControlPendingRequest( pendingRequests, diff --git a/src/shared/remote-runtime-shared-control-socket-generation.ts b/src/shared/remote-runtime-shared-control-socket-generation.ts index dda6ab8df5e7..7654c85389d2 100644 --- a/src/shared/remote-runtime-shared-control-socket-generation.ts +++ b/src/shared/remote-runtime-shared-control-socket-generation.ts @@ -4,6 +4,10 @@ import type { SharedControlLogicalSubscription } from './remote-runtime-shared-c export class SharedControlSocketGeneration { private current = 0 + peek(): number { + return this.current + } + isCurrent(generation: number): boolean { return generation === this.current } diff --git a/src/shared/repo-host-identity.ts b/src/shared/repo-host-identity.ts index d85c49be81cf..f4c148b31ef6 100644 --- a/src/shared/repo-host-identity.ts +++ b/src/shared/repo-host-identity.ts @@ -1,8 +1,13 @@ +import type { Repo } from './repo-types' + declare const hostScopedRepoKeyBrand: unique symbol // Why: for new code only; existing raw-id stores stay as they are (D7). export type HostScopedRepoKey = string & { readonly [hostScopedRepoKeyBrand]: true } +/** A repo write's target: the row's own identity fields, so its host can't be dropped or guessed. */ +export type RepoHostTarget = Pick + export function getRepoHostIdentityForParts(repoId: string, hostId: string): HostScopedRepoKey { // Why: host ids and repo ids can contain punctuation; NUL keeps the composite // key collision-free without escaping user/provider-owned strings. diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts index a2fbc120b20f..f122b5f1f8be 100644 --- a/src/shared/rpc-contract/files-mutation-params.ts +++ b/src/shared/rpc-contract/files-mutation-params.ts @@ -27,6 +27,15 @@ export const FileWrite = FileMutationOpen.extend({ .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' }) }) +// Why a separate method: a host too old to honor the precondition answers method_not_found +// instead of silently dropping the field and writing over newer disk content. +export const FileWriteIfUnchanged = FileWrite.extend({ + diskPrecondition: z.object({ + expectedSignature: z.string().min(1).max(64), + allowMissing: z.boolean().optional() + }) +}) + export const FileWriteBase64 = FileMutationOpen.extend({ contentBase64: z .unknown() diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts index 94fd5d399153..3f77171505d1 100644 --- a/src/shared/rpc-contract/project-runtime-params.ts +++ b/src/shared/rpc-contract/project-runtime-params.ts @@ -75,8 +75,10 @@ export const ProjectHostSetupCreate = z.object({ setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional() }) +// Why `hostId` is optional: older clients omit it, and older hosts strip it (first match by id). export const ProjectHostSetupUpdate = z.object({ setupId: requiredString('Missing setup ID'), + hostId: RequestedHostId.optional(), updates: z.object({ displayName: OptionalString, path: OptionalString, @@ -92,5 +94,6 @@ export const ProjectHostSetupUpdate = z.object({ export const ProjectHostSetupDelete = z.object({ setupId: requiredString('Missing setup ID'), + hostId: RequestedHostId.optional(), force: z.boolean().optional() }) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 54a765234e4d..fd232b39adfd 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -197,7 +197,8 @@ import { FileRename, FileWrite, FileWriteBase64, - FileWriteBase64Chunk + FileWriteBase64Chunk, + FileWriteIfUnchanged } from './files-mutation-params' import { DocPreviewFileRead, @@ -534,6 +535,7 @@ import { AcknowledgeAttentionParams, AgentsParams, AttachParams, + AvailabilityParams, CancelParams, ConversationCommandParams, CreateParams, @@ -552,6 +554,7 @@ import { RewindParams, SendParams, SetOptionParams, + SleepWorkspaceParams, SubscribeParams, SubscribeTurnCompletionsParams, ThreadGoalParams, @@ -711,7 +714,9 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.rewind': RewindParams, 'agentSession.send': SendParams, 'agentSession.setOption': SetOptionParams, + 'agentSession.sleepWorkspace': SleepWorkspaceParams, 'agentSession.subscribe': SubscribeParams, + 'agentSession.subscribeAvailability': AvailabilityParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, 'agentSession.threadGoal': ThreadGoalParams, @@ -905,6 +910,7 @@ export const RPC_PARAMS_BY_METHOD = { 'files.write': FileWrite, 'files.writeBase64': FileWriteBase64, 'files.writeBase64Chunk': FileWriteBase64Chunk, + 'files.writeIfUnchanged': FileWriteIfUnchanged, 'files.writeTerminalArtifact': TerminalArtifactFileWrite, 'folderWorkspace.create': FolderWorkspaceCreate, 'folderWorkspace.delete': FolderWorkspaceSelector, diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts index 037062fc4448..fd6b2d89d000 100644 --- a/src/shared/rpc-contract/session-tabs-schemas-params.ts +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -111,10 +111,6 @@ export const TerminalPaneLayoutNodeSchema = z }) ) -const PaneLayoutEditIntentSchema = z.literal('gesture') - -export type PaneLayoutEditIntent = z.infer - export const UpdatePaneLayout = WorktreeTabSelector.extend({ tabId: z .unknown() @@ -125,9 +121,8 @@ export const UpdatePaneLayout = WorktreeTabSelector.extend({ chatLeafId: z.string().max(128).nullable().optional(), titlesByLeafId: z.record(z.string(), z.string()).optional(), // Why: panes the user renamed in this edit; the rest of titlesByLeafId is a resend, not a rename. - renamedLeafIds: z.array(z.string().max(128)).max(64).optional().catch(undefined), - // Why: marks a user's layout gesture; .catch keeps a newer client's unknown intent from failing the edit. - intent: PaneLayoutEditIntentSchema.optional().catch(undefined) + // An older client's `intent: 'gesture'` tag is stripped unread: renamedLeafIds is the one delta. + renamedLeafIds: z.array(z.string().max(128)).max(64).optional().catch(undefined) }) export const SetTabProps = WorktreeTabSelector.extend({ diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index da29e7ab640c..aece5f4ac651 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -277,11 +277,15 @@ export const SetOptionParams = z .strict() export const OptionsParams = z.object({ sessionId: SessionId }).strict() +export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) /** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ export const AgentsParams = z.object({}).strict() +/** `agentSession.subscribeAvailability` takes nothing: the snapshot is the host's. */ +export const AvailabilityParams = z.strictObject({}) + /** `sessionId` scopes the catalog to that session's pinned account; without a * session record the host keys it by the account a new launch would pin. * `worktree` names where a new chat runs, whose own config may replace the default. diff --git a/src/shared/runtime-capability-constants.ts b/src/shared/runtime-capability-constants.ts index 3465ec1f1f85..470e0d85815d 100644 --- a/src/shared/runtime-capability-constants.ts +++ b/src/shared/runtime-capability-constants.ts @@ -1,4 +1,7 @@ export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const +// Why: older hosts strip `hostId` from setup update/delete and act on the first same-id twin. +export const PROJECT_HOST_SETUP_HOST_TARGET_RUNTIME_CAPABILITY = + 'project-host-setup.host-target.v1' as const export const TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY = 'task-source-context.v1' as const export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v1' as const export const WORKTREE_PROVISIONED_ROOT_RUNTIME_CAPABILITY = 'worktree.provisioned-root.v1' as const diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts index 2ba3acaa86dd..28bd3c2f0489 100644 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ b/src/shared/runtime-environment-managed-orcad-store.ts @@ -19,6 +19,7 @@ import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' import { resolveEnvironmentFromStore } from './runtime-environment-store' +import { findCollidingEnvironmentName } from './runtime-environment-name-collision' /** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */ export function addManagedOrcadEnvironment( @@ -42,10 +43,11 @@ export function addManagedOrcadEnvironment( `A server with id "${args.id}" already exists.` ) } - if (known.some((entry) => entry.name === args.name)) { + const namesake = findCollidingEnvironmentName(known, args.name) + if (namesake) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - `A server named "${args.name}" already exists.` + `A server named "${namesake.name}" already exists.` ) } const environment = createEnvironmentFromPairingOffer({ diff --git a/src/shared/runtime-environment-name-collision.ts b/src/shared/runtime-environment-name-collision.ts new file mode 100644 index 000000000000..ce7f1542d999 --- /dev/null +++ b/src/shared/runtime-environment-name-collision.ts @@ -0,0 +1,14 @@ +import { normalizeTypedName } from './typed-name-normalization' + +/** + * The saved server another server's name would be confused with. + * Why: `--environment ` resolves names with normalizeTypedName and refuses a name two + * servers share, so saving `Build` beside `build` makes both unreachable by name. + */ +export function findCollidingEnvironmentName( + environments: readonly T[], + name: string +): T | undefined { + const key = normalizeTypedName(name) + return environments.find((entry) => normalizeTypedName(entry.name) === key) +} diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index e5bbe45e33d3..d59d7870d498 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -110,6 +110,26 @@ describe('runtime environment store', () => { expect(listEnvironments(userDataPath)).toEqual([first]) }) + it('rejects a name that differs only in case or surrounding spaces, as --environment resolves it', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) + tempDirs.push(userDataPath) + + const first = addEnvironmentFromPairingCode(userDataPath, { + name: 'build', + pairingCode: pairingCode('ws://127.0.0.1:6768') + }) + + for (const name of ['Build', ' build ', '"build"']) { + expect(() => + addEnvironmentFromPairingCode(userDataPath, { + name, + pairingCode: pairingCode('ws://192.0.2.10:6768') + }) + ).toThrow('A server named "build" already exists.') + } + expect(listEnvironments(userDataPath)).toEqual([first]) + }) + it('advances pairing revisions across equal and backward clock readings', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-')) tempDirs.push(userDataPath) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 3f431be4f18e..bf4145e7d2b5 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -16,6 +16,7 @@ import { writeEnvironmentStore } from './runtime-environment-store-file' import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' +import { findCollidingEnvironmentName } from './runtime-environment-name-collision' export { getEnvironmentStorePath, @@ -44,11 +45,11 @@ export function addEnvironmentFromPairingCode( const offer = parseRemotePairingCode(userDataPath, args.pairingCode) const store = readPersistedEnvironmentStore(userDataPath) const now = args.now ?? Date.now() - const existing = store.environments.find((entry) => entry.name === args.name) + const existing = findCollidingEnvironmentName(store.environments, args.name) if (existing) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - `A server named "${args.name}" already exists.` + `A server named "${existing.name}" already exists.` ) } const environment = createEnvironmentFromPairingOffer({ diff --git a/src/shared/runtime-environment-wsl-host-store.ts b/src/shared/runtime-environment-wsl-host-store.ts index 7e5b332afc12..1ba9896dc664 100644 --- a/src/shared/runtime-environment-wsl-host-store.ts +++ b/src/shared/runtime-environment-wsl-host-store.ts @@ -17,6 +17,7 @@ import { writeEnvironmentStore } from './runtime-environment-store-file' import { resolveEnvironmentFromStore } from './runtime-environment-store' +import { findCollidingEnvironmentName } from './runtime-environment-name-collision' import { readWslHostDeployments, writeWslHostDeployment, @@ -72,10 +73,11 @@ export function addWslHostEnvironment( `A server with id "${args.id}" already exists.` ) } - if (known.some((entry) => entry.name === args.name)) { + const namesake = findCollidingEnvironmentName(known, args.name) + if (namesake) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - `A server named "${args.name}" already exists.` + `A server named "${namesake.name}" already exists.` ) } const now = args.now ?? Date.now() diff --git a/src/shared/sidebar-list/host-section-rows.ts b/src/shared/sidebar-list/host-section-rows.ts index 43b0b5cd1d5b..e7989428b2ff 100644 --- a/src/shared/sidebar-list/host-section-rows.ts +++ b/src/shared/sidebar-list/host-section-rows.ts @@ -1,7 +1,6 @@ import { ALL_EXECUTION_HOSTS_SCOPE, LOCAL_EXECUTION_HOST_ID, - getLocalExecutionHostLabel, getRepoExecutionHostId, getWorktreeExecutionHostId, type ExecutionHostId, @@ -74,12 +73,12 @@ function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId } } -function getFallbackHost(hostId: ExecutionHostId): HostSectionOption { +function getFallbackHost(hostId: ExecutionHostId, localHostLabel: string): HostSectionOption { const isLocal = hostId === LOCAL_EXECUTION_HOST_ID return { id: hostId, kind: isLocal ? 'local' : hostId.startsWith('ssh:') ? 'ssh' : 'runtime', - label: isLocal ? getLocalExecutionHostLabel() : hostId, + label: isLocal ? localHostLabel : hostId, detail: isLocal ? 'This computer' : 'Host', health: isLocal ? 'local' : 'available' } @@ -173,6 +172,8 @@ export function addHostSectionRows(args: { workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null defaultHostId: ExecutionHostId + /** The listed host's own name for itself; a phone reader must not derive it from its own platform. */ + localHostLabel: string // Why: host sections reuse the sidebar's persisted collapsed-group keys // (`host:`) so collapse state survives restarts like other groups. collapsedHostKeys?: ReadonlySet @@ -287,7 +288,7 @@ export function addHostSectionRows(args: { if (!hostRows || hostRows.length === 0) { continue } - const host = hostOptionsById.get(hostId) ?? getFallbackHost(hostId) + const host = hostOptionsById.get(hostId) ?? getFallbackHost(hostId, args.localHostLabel) const collapsed = args.forceCollapseHosts || (args.collapsedHostKeys?.has(`host:${host.id}`) ?? false) result.push({ diff --git a/src/shared/sidebar-list/paired-server-activity-facts.test.ts b/src/shared/sidebar-list/paired-server-activity-facts.test.ts new file mode 100644 index 000000000000..34ef0287006f --- /dev/null +++ b/src/shared/sidebar-list/paired-server-activity-facts.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../execution-host' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { buildPairedServerActivityFacts } from './paired-server-activity-facts' +import { normalizePairedServerRow, type PairedServerWorkspaceRow } from './paired-server-rows' + +const HOST = 'runtime:env-1' + +function serverRow(worktreeId: string, fields: Record = {}) { + const row = normalizePairedServerRow(HOST, { + worktreeId, + repoId: 'repo', + path: `/srv/${worktreeId}`, + branch: worktreeId, + liveTerminalCount: 0, + status: 'inactive', + ...fields + }) + if (!row) { + throw new Error('row did not normalize') + } + return row +} + +function build( + rows: PairedServerWorkspaceRow[], + options: { + freshness?: 'live' | 'unverifiable' + session?: Pick + } = {} +) { + const asked: ExecutionHostId[] = [] + const facts = buildPairedServerActivityFacts( + [{ hostId: HOST, freshness: options.freshness ?? 'live', rows }], + (hostId) => { + asked.push(hostId) + return options.session ?? null + } + ) + return { facts, asked } +} + +describe('paired server activity facts', () => { + it("reads the server's own terminals and agents off its rows", () => { + const { facts } = build([ + serverRow('live', { liveTerminalCount: 2, status: 'active' }), + serverRow('lost', { unverifiableTerminalCount: 1 }), + serverRow('agent', { status: 'working', workingMode: 'monitoring' }), + serverRow('asks', { liveTerminalCount: 1, status: 'permission' }), + serverRow('idle', { status: 'done' }) + ]) + expect(facts).toEqual({ + live: { terminal: 'live', browser: false, structuredChat: false, agent: null }, + lost: { terminal: 'unverifiable', browser: false, structuredChat: false, agent: null }, + agent: { terminal: null, browser: false, structuredChat: false, agent: 'monitoring' }, + asks: { terminal: 'live', browser: false, structuredChat: false, agent: 'permission' } + }) + }) + + it('keeps a server out of contact unverifiable, never asleep', () => { + const { facts } = build([serverRow('live', { liveTerminalCount: 1, status: 'active' })], { + freshness: 'unverifiable' + }) + expect(facts.live?.terminal).toBe('unverifiable') + }) + + it("adds the browser tabs this desktop's session holds in a server workspace", () => { + const { facts, asked } = build([serverRow('browsing')], { + session: { + browserTabsByWorktree: { + browsing: [ + { + id: 'b1', + worktreeId: 'browsing', + url: 'about:blank', + title: '', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + unifiedTabs: {} + } + }) + expect(asked).toEqual([HOST]) + expect(facts.browsing).toEqual({ + terminal: null, + browser: true, + structuredChat: false, + agent: null + }) + }) + + it('an older server that sends no counts reports nothing', () => { + const row = normalizePairedServerRow(HOST, { worktreeId: 'old', repoId: 'repo' }) + expect(row?.activity).toEqual({ terminal: null, agent: null }) + }) +}) diff --git a/src/shared/sidebar-list/paired-server-activity-facts.ts b/src/shared/sidebar-list/paired-server-activity-facts.ts new file mode 100644 index 000000000000..251297ca043f --- /dev/null +++ b/src/shared/sidebar-list/paired-server-activity-facts.ts @@ -0,0 +1,56 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../execution-host' +import type { WorkspaceSessionState } from '../workspace-session-state-types' +import { getWorkspaceSessionTabActivity } from '../workspace-session-tab-activity' +import { worktreeIdentityKey } from '../worktree/id' +import type { PairedServerRowsSnapshot } from './paired-server-rows' +import { isIdleWorkspaceActivity, type WorkspaceActivityFacts } from './workspace-activity-facts' + +/** + * A paired server's rows under the server's bare ids, like the review facts: its terminals and + * agents as the server reports them, and the browser and chat tabs this desktop's session holds + * there, which only the desktop knows. Rows from a server out of contact keep their terminals + * unverifiable, never gone (docs/reference/ssh-execution-boundary.md). + */ +export function buildPairedServerActivityFacts( + servers: readonly Pick< + PairedServerRowsSnapshot['servers'][number], + 'hostId' | 'freshness' | 'rows' + >[], + sessionOf: ( + hostId: ExecutionHostId + ) => Pick | null +): Record { + const facts: Record = {} + for (const server of servers) { + const parsed = parseExecutionHostId(server.hostId) + if (parsed?.kind !== 'runtime') { + continue + } + const tabs = getWorkspaceSessionTabActivity( + sessionOf(toRuntimeExecutionHostId(parsed.environmentId)) + ) + const browser = new Set([...tabs.browser].map(worktreeIdentityKey)) + const structuredChat = new Set([...tabs.structuredChat].map(worktreeIdentityKey)) + for (const row of server.rows) { + const reported = row.activity + const key = worktreeIdentityKey(row.worktreeId) + const fact: WorkspaceActivityFacts = { + terminal: + reported?.terminal === 'live' && server.freshness !== 'live' + ? 'unverifiable' + : (reported?.terminal ?? null), + browser: browser.has(key), + structuredChat: structuredChat.has(key), + agent: reported?.agent ?? null + } + if (!isIdleWorkspaceActivity(fact)) { + facts[row.worktreeId] = fact + } + } + } + return facts +} diff --git a/src/shared/sidebar-list/paired-server-rows.ts b/src/shared/sidebar-list/paired-server-rows.ts index f3cbd6aea6f7..2e0abd8ebfc0 100644 --- a/src/shared/sidebar-list/paired-server-rows.ts +++ b/src/shared/sidebar-list/paired-server-rows.ts @@ -5,6 +5,7 @@ import { openEnum } from '../zod-salvage' import { DEFAULT_WORKSPACE_STATUS_ID } from '../workspace-statuses' import { SMART_TIERS } from './sort-stamps' import { WorkspaceRowSchema } from './workspace-rows' +import { HostReportedActivitySchema, type HostReportedActivity } from './workspace-activity-facts' /** One paired-server workspace as the sidebar lists it, keyed by its host-qualified id. */ const PairedServerWorkspaceRowSchema = z @@ -41,6 +42,10 @@ const PairedServerWorkspaceRowSchema = z smartAt: true }).partial().shape ) + .extend({ + /** The server's own terminals and agents there; older desktops omit it. */ + activity: HostReportedActivitySchema.optional().catch(undefined) + }) /** See PairedServerHost; health and compatibility stay open strings, as a reader only compares them. */ const PairedServerHostFactsSchema = z.object({ @@ -96,9 +101,26 @@ const ServerWorktreePsRowSchema = z.object({ detachedHead: z.literal(true).optional().catch(undefined), lastActivityAt: z.number().optional().catch(undefined), smartTier: z.enum(SMART_TIERS).optional().catch(undefined), - smartAt: z.number().optional().catch(undefined) + smartAt: z.number().optional().catch(undefined), + liveTerminalCount: z.number().optional().catch(undefined), + unverifiableTerminalCount: z.number().optional().catch(undefined), + status: z.string().optional().catch(undefined), + workingMode: z.string().optional().catch(undefined) }) +function reportedActivity(row: z.infer): HostReportedActivity { + const working = row.workingMode === 'monitoring' ? 'monitoring' : 'working' + return { + terminal: + (row.liveTerminalCount ?? 0) > 0 + ? 'live' + : (row.unverifiableTerminalCount ?? 0) > 0 + ? 'unverifiable' + : null, + agent: row.status === 'permission' ? 'permission' : row.status === 'working' ? working : null + } +} + /** A server's own `worktree.ps` row, with defaults for what older servers omit; null without an id. */ export function normalizePairedServerRow( hostId: ExecutionHostId, @@ -116,6 +138,10 @@ export function normalizePairedServerRow( parentWorktreeId, lineageWorktreeInstanceId, parentWorktreeInstanceId, + liveTerminalCount: _liveTerminalCount, + unverifiableTerminalCount: _unverifiableTerminalCount, + status: _status, + workingMode: _workingMode, ...fields } = parsed.data return { @@ -132,6 +158,7 @@ export function normalizePairedServerRow( parentWorktreeInstanceId } : null, - recentAt: lastActivityAt + recentAt: lastActivityAt, + activity: reportedActivity(parsed.data) } } diff --git a/src/shared/sidebar-list/row-builders.ts b/src/shared/sidebar-list/row-builders.ts index 1744636e1717..a32bfc67843d 100644 --- a/src/shared/sidebar-list/row-builders.ts +++ b/src/shared/sidebar-list/row-builders.ts @@ -1,6 +1,7 @@ import type { Repo } from '../repo-types' import type { Worktree } from '../worktree/types' import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity' +import { getFolderWorkspaceHostIdentity } from '../folder-workspace-worktree' import { getWorktreeLineageGroupKey } from './group-keys' import { getExpandedWorktreeOccurrences, @@ -107,14 +108,15 @@ export function appendWorktreeRows( } /** The one folder-workspace row constructor, shared by the project-group, - * grouped-lane and flat emitters so their rows cannot diverge. */ + * grouped-lane and flat emitters so their rows cannot diverge. Keyed by host: two hosts can + * share a folder id. */ export function buildFolderWorkspaceRow( pair: RenderableFolderWorkspace, groupDepth: number ): FolderWorkspaceRow { return { type: 'folder-workspace', - key: `folder-workspace:${pair.folderWorkspace.id}`, + key: `folder-workspace:${getFolderWorkspaceHostIdentity(pair.folderWorkspace)}`, folderWorkspace: pair.folderWorkspace, projectGroup: pair.projectGroup, depth: 0, diff --git a/src/shared/sidebar-list/sidebar-feed-index.ts b/src/shared/sidebar-list/sidebar-feed-index.ts index b765b7176842..f213969063f4 100644 --- a/src/shared/sidebar-list/sidebar-feed-index.ts +++ b/src/shared/sidebar-list/sidebar-feed-index.ts @@ -55,7 +55,8 @@ export function buildFeedHostOptions( id: parsed.environmentId, name: host.label })), - hostLabelOverrides: view.hostLabelOverrides + hostLabelOverrides: view.hostLabelOverrides, + localHostLabel: view.localHostLabel }).map((host) => { const label = serverLabelByHostId.get(host.id) return label ? { ...host, label } : host diff --git a/src/shared/sidebar-list/sidebar-feed-rows.ts b/src/shared/sidebar-list/sidebar-feed-rows.ts index a887154e74d4..81ea59946772 100644 --- a/src/shared/sidebar-list/sidebar-feed-rows.ts +++ b/src/shared/sidebar-list/sidebar-feed-rows.ts @@ -70,6 +70,8 @@ export type SidebarFeedView = { /** The host the desktop has focused (its settings' active runtime), where unowned rows land. */ defaultHostId: ExecutionHostId hostLabelOverrides: ReadonlyMap + /** The listed host's name for itself when the user has not renamed it. */ + localHostLabel: string /** The desktop window's own saved host order, which it writes; a phone reads the host's. */ workspaceHostOrder?: readonly ExecutionHostId[] } @@ -276,6 +278,7 @@ export function renderSidebarFeedRows( workspaceHostScope: view.workspaceHostScope, visibleWorkspaceHostIds: view.visibleWorkspaceHostIds, defaultHostId: view.defaultHostId, + localHostLabel: view.localHostLabel, collapsedHostKeys: collapsed, forceCollapseHosts, preferProjectGrouping: true diff --git a/src/shared/sidebar-list/sidebar-inputs-feed-contract.ts b/src/shared/sidebar-list/sidebar-inputs-feed-contract.ts index 8ca0b7bf4712..86ea9b0ae062 100644 --- a/src/shared/sidebar-list/sidebar-inputs-feed-contract.ts +++ b/src/shared/sidebar-list/sidebar-inputs-feed-contract.ts @@ -3,8 +3,11 @@ import { openEnum } from '../zod-salvage' /** The reply's shape; an added optional field keeps it. */ export const SIDEBAR_INPUTS_SCHEMA_VERSION = 1 -/** What the inputs mean: bumps whenever the same inputs would build a different list. */ -export const SIDEBAR_INPUTS_SEMANTICS_VERSION = 1 +/** + * What the inputs mean: bumps whenever the same inputs would build a different list. + * 2: desktop facts carry the focused host, host labels and pinned placement (#30462). + */ +export const SIDEBAR_INPUTS_SEMANTICS_VERSION = 2 /** A page's encoded ceiling. A phone's bridge frame (640 KiB) holds it with the RPC envelope. */ export const SIDEBAR_INPUTS_PAGE_MAX_BYTES = 256 * 1024 diff --git a/src/shared/sidebar-list/workspace-activity-facts.ts b/src/shared/sidebar-list/workspace-activity-facts.ts index 096091f397ca..0d47ccf5208c 100644 --- a/src/shared/sidebar-list/workspace-activity-facts.ts +++ b/src/shared/sidebar-list/workspace-activity-facts.ts @@ -19,10 +19,17 @@ export const WorkspaceActivityFactsSnapshotSchema = z.object({ facts: z.record(z.string(), WorkspaceActivityFactsSchema) }) +/** What a paired server reports for one of its own workspaces; the tabs are its clients' to add. */ +export const HostReportedActivitySchema = WorkspaceActivityFactsSchema.pick({ + terminal: true, + agent: true +}) + export type LiveAgentWorktreeStatus = z.infer export type WorkspaceActivityFacts = z.infer export type WorkspaceTerminalLiveness = NonNullable export type WorkspaceActivityFactsSnapshot = z.infer +export type HostReportedActivity = z.infer export const IDLE_WORKSPACE_ACTIVITY: WorkspaceActivityFacts = { terminal: null, diff --git a/src/shared/ssh-pending-pty-kill.ts b/src/shared/ssh-pending-pty-kill.ts index 6619dc0fc7da..b7fa477fb36a 100644 --- a/src/shared/ssh-pending-pty-kill.ts +++ b/src/shared/ssh-pending-pty-kill.ts @@ -1,6 +1,6 @@ /** A `pty.shutdown` a relay-era client issued to an SSH host and could not confirm, carried on its - * `SshRemotePtyLease`. This build never records or replays one; it keeps the field through - * load and save so a downgraded build can still replay it. */ + * `SshRemotePtyLease`. This build records and replays one only through the legacy relay bridge, + * for a closed tab whose older relay still runs its shell; a downgraded build replays it too. */ export type SshPendingPtyKill = { requestedAt: number /** The host-minted PTY incarnation this kill was aimed at, and the fence. diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 6e5504e256c1..b8db238a0c4f 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -34,12 +34,15 @@ export type SshRemoteRuntimeResolution = { orcaMajor: number } +/** Who created a hidden target as an implementation detail, and so owns its lifecycle. */ +export type SshTargetOwner = { type: 'on-demand-runtime'; runtimeId: string } + export type SshTarget = { id: string label: string /** Internal owner for targets that Orca creates as implementation details. * Owned targets are hidden from normal SSH-host management surfaces. */ - owner?: { type: 'on-demand-runtime'; runtimeId: string } + owner?: SshTargetOwner /** Host alias to resolve through OpenSSH config (ssh -G). */ configHost?: string host: string diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index b4aa2ca38963..f78c3b48f1ed 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -163,15 +163,32 @@ describe('the tab id a create reserves', () => { }) it('negotiates a strict pane identity and includes it in the create fingerprint without accepting paths', () => { - const source = { providerSessionId: 'thread-abc', cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } } + const source = { + providerSessionId: 'thread-abc', + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } + } expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY) expect(ResumeSource.parse(source)).toEqual(source) expect(ResumeSource.safeParse({ ...source, transcriptPath: '/client/path' }).success).toBe(false) - expect(ResumeSource.safeParse({ ...source, cliSource: { ...source.cliSource, accountHome: '/client/home' } }).success).toBe(false) + expect( + ResumeSource.safeParse({ + ...source, + cliSource: { ...source.cliSource, accountHome: '/client/home' } + }).success + ).toBe(false) const first = createParams({ resumeFrom: source }) - expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint) - expect(first.envelope.payloadFingerprint).toBe(createParams({ resumeFrom: source }).envelope.payloadFingerprint) - expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: { - ...source, cliSource: { ...source.cliSource, paneKey: 'other:leaf' } - } }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).not.toBe( + createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint + ) + expect(first.envelope.payloadFingerprint).toBe( + createParams({ resumeFrom: source }).envelope.payloadFingerprint + ) + expect(first.envelope.payloadFingerprint).not.toBe( + createParams({ + resumeFrom: { + ...source, + cliSource: { ...source.cliSource, paneKey: 'other:leaf' } + } + }).envelope.payloadFingerprint + ) }) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 6a0237ce272e..0546d7137ef0 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,5 +1,8 @@ import { sha256 } from './sha256' -import type { StructuredAgentSessionFirstMessage, StructuredAgentSessionResumeSource } from './structured-agent-session-create' +import type { + StructuredAgentSessionFirstMessage, + StructuredAgentSessionResumeSource +} from './structured-agent-session-create' import type { StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 1a619ff348f7..ae134fe2acaa 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -41,7 +41,7 @@ export function structuredAgentSessionOptionCatalog( models.push({ id: result.current.model, label: result.current.model, - options: seed.unknownModelOptions ?? [] + options: seed.liveModelOptionsAreAuthoritative ? [] : (seed.unknownModelOptions ?? []) }) } return { ...seed, models, defaultModelIsCliDefault: true } diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index d8bdf74c27d3..a79dc75bd85e 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -35,6 +35,11 @@ export const AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY = /** Strict create source identity is sent only after host negotiation. */ export const STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY = 'agent-session.structured.cli-source.v1' as const +// Why: `agentSession.subscribeAvailability` is a new stream with strict params; a decoder drops an +// unknown opcode silently, so a client subscribes only to a host advertising this and otherwise +// shows its own facts, leaving the host's verdict to the click's createSupport. +export const STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY = + 'agent-session.structured.availability.v1' as const export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, @@ -43,5 +48,6 @@ export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, - AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY + AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/telemetry-ssh-host-server-event-schemas.ts b/src/shared/telemetry-ssh-host-server-event-schemas.ts index 54eb07918003..07aa11fdd2c4 100644 --- a/src/shared/telemetry-ssh-host-server-event-schemas.ts +++ b/src/shared/telemetry-ssh-host-server-event-schemas.ts @@ -14,6 +14,7 @@ const UNAVAILABLE_REASONS = [ 'artifacts_unavailable', 'libc_unidentified', 'runtime_self_test', + 'runtime_noexec', 'security_software', 'native_preflight' ] as const diff --git a/src/shared/typed-name-normalization.ts b/src/shared/typed-name-normalization.ts new file mode 100644 index 000000000000..5d321ce3514c --- /dev/null +++ b/src/shared/typed-name-normalization.ts @@ -0,0 +1,11 @@ +export function stripWrappingQuotes(value: string): string { + const quote = value[0] + return value.length >= 2 && (quote === "'" || quote === '"') && value.at(-1) === quote + ? value.slice(1, -1) + : value +} + +/** How a typed host, server or project name is compared: trimmed, unquoted, case-insensitive. */ +export function normalizeTypedName(value: string): string { + return stripWrappingQuotes(value.trim()).trim().toLowerCase() +} diff --git a/src/shared/workspace-layout/old-client/old-client-pane-layout-translation.ts b/src/shared/workspace-layout/old-client/old-client-pane-layout-translation.ts index 699bd26e1c2c..2a04f65528c4 100644 --- a/src/shared/workspace-layout/old-client/old-client-pane-layout-translation.ts +++ b/src/shared/workspace-layout/old-client/old-client-pane-layout-translation.ts @@ -27,7 +27,7 @@ export type SentPaneLayout = { titlesByLeafId?: Record } -/** `expandedLeafId` is view state and `intent` a retired gesture tag: neither is read. */ +/** `expandedLeafId` is view state, so it is not read. */ export function translateUpdatePaneLayout( workspaceKey: string, workspace: WorkspaceLayout, diff --git a/src/shared/workspace-session-tab-activity.ts b/src/shared/workspace-session-tab-activity.ts index b7547b1331da..8745cd388e2b 100644 --- a/src/shared/workspace-session-tab-activity.ts +++ b/src/shared/workspace-session-tab-activity.ts @@ -2,25 +2,37 @@ import type { WorkspaceSessionState } from './workspace-session-state-types' import { isStructuredTab } from './structured-agent-session-tab' import { normalizeWorkspaceSessionKeyToWorkspaceId } from './workspace-scope' +type SessionTabs = Pick + /** * Workspaces a saved session holds open browser or native-chat tabs for: the two signals of the * desktop sidebar's activity rule (worktree-activity-state.ts) that live in the client's session * rather than on the execution host. Live terminals and agents are the host's to report. Reads * the saved session's keys; the renderer's live-state twin is getWorktreeIdsWithStructuredChat. */ -export function getWorkspaceSessionTabActivityIds( - session: Pick | null | undefined -): Set { - const ids = new Set() +export function getWorkspaceSessionTabActivity(session: SessionTabs | null | undefined): { + browser: Set + structuredChat: Set +} { + const browser = new Set() + const structuredChat = new Set() for (const [key, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) { if (tabs.length > 0) { - ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + browser.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) } } for (const [key, tabs] of Object.entries(session?.unifiedTabs ?? {})) { if (tabs.some(isStructuredTab)) { - ids.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) + structuredChat.add(normalizeWorkspaceSessionKeyToWorkspaceId(key)) } } - return ids + return { browser, structuredChat } +} + +/** Either kind of getWorkspaceSessionTabActivity. */ +export function getWorkspaceSessionTabActivityIds( + session: SessionTabs | null | undefined +): Set { + const { browser, structuredChat } = getWorkspaceSessionTabActivity(session) + return new Set([...browser, ...structuredChat]) } diff --git a/src/shared/workspace-unread-raise-capability.ts b/src/shared/workspace-unread-raise-capability.ts new file mode 100644 index 000000000000..3aea8f4a34a1 --- /dev/null +++ b/src/shared/workspace-unread-raise-capability.ts @@ -0,0 +1,15 @@ +import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, type RuntimeCapability } from './protocol-version' + +// Why: exactly one side raises a workspace's unread flag. A host advertising this raises it itself +// (ORCA_SIDEBAR_INPUTS=main), so a window raises it only on a host without it. A client advertising +// it leaves raising to such a host; one that hosts browser pages without it raises by its own rule. +export const WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY = 'workspace-unread.host-raises.v1' as const + +/** A desktop that raises the flag itself: only desktops host client pages, and every one since + * that shipped (#15448) advertises it. */ +export function clientRaisesUnreadFlagItself(capabilities: readonly RuntimeCapability[]): boolean { + return ( + capabilities.includes(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY) && + !capabilities.includes(WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY) + ) +} diff --git a/src/shared/wsl-move/wsl-move-storage-dispositions.ts b/src/shared/wsl-move/wsl-move-storage-dispositions.ts index 8f21b0279697..1311884c0b28 100644 --- a/src/shared/wsl-move/wsl-move-storage-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-storage-dispositions.ts @@ -98,6 +98,7 @@ export const SQLITE_SCHEMA_FILE_DATABASES: Record = { 'src/main/ai-vault-search/session-search-query-schema.ts': 'ai-vault-search', 'src/main/ai-vault-search/session-search-schema.ts': 'ai-vault-search', 'src/main/ai-vault/session-parse-cache-database.ts': 'ai-vault-parse-cache', + 'src/main/ai-vault/session-scope-cwds-table.ts': 'ai-vault-parse-cache', 'src/main/browser/browser-cookie-staged-import.ts': 'browser-cookie-import', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts': 'agent-session-journal', @@ -219,7 +220,7 @@ export const SQLITE_TABLE_DISPOSITIONS: Record ), ...tables( 'ai-vault-parse-cache', - ['meta', 'rows'], + ['meta', 'rows', 'scope_cwds'], notApplicableDisposition(57, 'Session History parse cache; rebuilt from transcripts.') ), ...tables( diff --git a/tests/e2e/agent-send-picker-reveal.spec.ts b/tests/e2e/agent-send-picker-reveal.spec.ts index 07468786cf53..14ac4be513a3 100644 --- a/tests/e2e/agent-send-picker-reveal.spec.ts +++ b/tests/e2e/agent-send-picker-reveal.spec.ts @@ -1,255 +1,195 @@ import { expect, test } from './helpers/orca-app' -import { - waitForActiveWorktree, - waitForSessionReady, - waitForStartupWorktreeRefresh -} from './helpers/store' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { launchWithUnreachableSshWorkspaces } from './helpers/seeded-ssh-workspaces' -for (const hostId of ['local', 'ssh:picker-builder'] as const) { +for (const host of ['local', 'ssh'] as const) { for (const hiddenBy of ['host', 'parent'] as const) { - test(`agent send picker reveals ${hostId} target hidden by ${hiddenBy} and restores collapse on close`, async ({ - orcaPage + test(`agent send picker reveals ${host} target hidden by ${hiddenBy} and restores collapse on close`, async ({ + testRepoPath }, testInfo) => { - await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await waitForStartupWorktreeRefresh(orcaPage) - const ids = await orcaPage.evaluate( - async ({ hostId, hiddenBy }) => { - const store = window.__store! - const state = store.getState() - const sourceRepo = state.repos[0] - const source = state.worktreesByRepo[sourceRepo.id][0] - const remoteHostId = hostId === 'local' ? 'ssh:picker-builder' : hostId - const remoteRepo = { - ...sourceRepo, - id: 'picker-remote-repo', - displayName: 'Remote project', - executionHostId: remoteHostId, - connectionId: remoteHostId.startsWith('ssh:') ? 'picker-builder' : null - } - const local = { - ...source, - hostId: 'local' as const, - isPinned: false, - displayName: 'Local workspace' - } - const remote = { - ...source, - id: 'picker-remote-parent', - repoId: remoteRepo.id, - hostId: remoteHostId, - instanceId: 'remote-parent-instance', - isMainWorktree: false, - isPinned: false, - displayName: 'Remote workspace' - } - const parent = hostId === 'local' ? local : remote - const now = Date.now() - const localChild = state.worktreesByRepo[sourceRepo.id].find( - (worktree) => worktree.id !== source.id - ) - const child = - hostId === 'local' - ? localChild - : { - ...parent, - id: 'picker-child', - instanceId: 'picker-child-instance', - isMainWorktree: false - } - if (hiddenBy === 'parent' && !child) { - throw new Error('Seeded child workspace missing') - } - const targetId = hiddenBy === 'parent' ? child!.id : parent.id - const target = { - ...parent, - ...(hiddenBy === 'parent' ? child : {}), - displayName: hiddenBy === 'parent' ? 'Child workspace' : parent.displayName, - diffComments: [ - { - id: 'picker-note', - worktreeId: targetId, - filePath: 'README.md', - source: 'diff' as const, - lineNumber: 1, - body: 'Check sidebar disclosure', - side: 'modified' as const, - createdAt: now - } - ] - } - if (hostId === 'local') { - await state.updateWorktreeMeta( - target.id, + test.setTimeout(3 * 60_000) + const launched = await launchWithUnreachableSshWorkspaces(testInfo, testRepoPath, { + label: 'Remote builder', + workspaces: [ + { name: 'parent', displayName: 'Remote workspace' }, + { name: 'child', displayName: 'Remote child' } + ] + }) + try { + const { page, ssh, localWorktreeId } = launched + await page.emulateMedia({ reducedMotion: 'reduce' }) + const ids = await evaluatePolled( + page, + async ({ host, hiddenBy, ssh, localWorktreeId }) => { + const store = window.__store! + const state = store.getState() + const localChild = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.hostId === 'local' && worktree.id !== localWorktreeId) + const hostId = host === 'local' ? 'local' : ssh.hostId + const parent = host === 'local' ? localWorktreeId : ssh.worktreeIds.parent! + const child = host === 'local' ? localChild?.id : ssh.worktreeIds.child + if (hiddenBy === 'parent' && !child) { + throw new Error('Seeded child workspace missing') + } + const target = hiddenBy === 'parent' ? child! : parent + const now = Date.now() + const noted = await state.updateWorktreeMeta( + target, { - displayName: target.displayName, + ...(hiddenBy === 'parent' ? { displayName: 'Child workspace' } : {}), isPinned: false, - diffComments: target.diffComments + diffComments: [ + { + id: 'picker-note', + worktreeId: target, + filePath: 'README.md', + source: 'diff' as const, + lineNumber: 1, + body: 'Check sidebar disclosure', + side: 'modified' as const, + createdAt: now + } + ] }, - { executionHostId: 'local' } + { executionHostId: hostId } ) + if (!noted.ok) { + throw new Error(`Writing the note failed: ${noted.error}`) + } if (hiddenBy === 'parent') { - await state.assignWorktreeParent(target.id, { parentWorktreeId: parent.id }) + await state.assignWorktreeParent(target, { parentWorktreeId: parent }) } - } - const localWorktrees = - hostId === 'local' ? (hiddenBy === 'parent' ? [local, target] : [target]) : [local] - const remoteWorktrees = - hostId !== 'local' ? (hiddenBy === 'parent' ? [remote, target] : [target]) : [remote] - const collapsedGroups = [ - hiddenBy === 'host' ? `host:${hostId}` : `lineage:${hostId}|${parent.id}` - ] - const worktreeLineageById = - hiddenBy === 'parent' - ? { - [target.id]: { - worktreeId: target.id, - worktreeInstanceId: target.instanceId!, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId!, - origin: 'orchestration' as const, - capture: { - source: 'orchestration-context' as const, - confidence: 'explicit' as const - }, + const targetRepoId = Object.values(store.getState().worktreesByRepo) + .flat() + .find((worktree) => worktree.id === target)!.repoId + const collapsedGroups = [ + hiddenBy === 'host' ? `host:${hostId}` : `lineage:${hostId}|${parent}` + ] + const tabId = 'picker-agent-tab' + const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const paneKey = `${tabId}:${leafId}` + const ptyId = 'picker-agent-pty' + await window.api.ui.set({ groupBy: 'none', collapsedGroups }) + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + state.setRightSidebarTab('source-control') + state.setRightSidebarOpen(true) + const current = store.getState() + store.setState({ + groupBy: 'none', + visibleWorkspaceHostIds: ['local', ssh.hostId], + workspaceHostScope: 'all', + activeRepoId: targetRepoId, + activeWorktreeId: target, + activeWorkspaceKey: `worktree:${target}`, + activeWorkspaceExecutionHostId: hostId, + pendingRevealWorktree: null, + agentSendPopoverTargetMode: null, + collapsedGroups: new Set(collapsedGroups), + agentActivityDisplayMode: 'compact', + worktreeCardProperties: [ + ...current.worktreeCardProperties.filter( + (property) => property !== 'inline-agents' + ), + 'inline-agents' + ], + tabsByWorktree: { + ...current.tabsByWorktree, + [target]: [ + ...(current.tabsByWorktree[target] ?? []), + { + id: tabId, + worktreeId: target, + ptyId, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 1, createdAt: now } + ] + }, + terminalLayoutsByTabId: { + ...current.terminalLayoutsByTabId, + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } } - : {} - const tabId = 'picker-agent-tab' - const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - const paneKey = `${tabId}:${leafId}` - const ptyId = 'picker-agent-pty' - await window.api.ui.set({ groupBy: 'none', collapsedGroups }) - state.setSidebarOpen(true) - state.setShowSleepingWorkspaces(true) - state.setHideDefaultBranchWorkspace(false) - state.setFilterRepoIds([]) - state.setRightSidebarTab('source-control') - state.setRightSidebarOpen(true) - store.setState({ - groupBy: 'none', - repos: [sourceRepo, remoteRepo], - worktreesByRepo: { [sourceRepo.id]: localWorktrees, [remoteRepo.id]: remoteWorktrees }, - worktreeLineageById, - visibleWorkspaceHostIds: ['local', remoteHostId], - workspaceHostScope: 'all', - activeRepoId: target.repoId, - activeWorktreeId: target.id, - activeWorkspaceKey: `worktree:${target.id}`, - activeWorkspaceExecutionHostId: hostId, - pendingRevealWorktree: null, - agentSendPopoverTargetMode: null, - collapsedGroups: new Set(collapsedGroups), - agentActivityDisplayMode: 'compact', - worktreeCardProperties: [ - ...state.worktreeCardProperties.filter((property) => property !== 'inline-agents'), - 'inline-agents' - ], - sshTargetLabels: new Map([['picker-builder', 'Remote builder']]), - sshConnectionStates: new Map([ - [ - 'picker-builder', - { - targetId: 'picker-builder', - status: 'connected', - error: null, - reconnectAttempt: 0 - } - ] - ]), - tabsByWorktree: { - ...state.tabsByWorktree, - [target.id]: [ - ...(state.tabsByWorktree[target.id] ?? []), - { - id: tabId, - worktreeId: target.id, - ptyId, - title: 'Claude', - customTitle: null, - color: null, - sortOrder: 1, - createdAt: now + }, + ptyIdsByTabId: { ...current.ptyIdsByTabId, [tabId]: [ptyId] }, + agentStatusByPaneKey: { + ...current.agentStatusByPaneKey, + [paneKey]: { + state: 'working', + prompt: 'Check sidebar disclosure', + updatedAt: now, + stateStartedAt: now, + agentType: 'claude', + paneKey, + worktreeId: target, + stateHistory: [] } - ] - }, - terminalLayoutsByTabId: { - ...state.terminalLayoutsByTabId, - [tabId]: { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: ptyId } - } - }, - ptyIdsByTabId: { ...state.ptyIdsByTabId, [tabId]: [ptyId] }, - agentStatusByPaneKey: { - ...state.agentStatusByPaneKey, - [paneKey]: { - state: 'working', - prompt: 'Check sidebar disclosure', - updatedAt: now, - stateStartedAt: now, - agentType: 'claude', - paneKey, - worktreeId: target.id, - stateHistory: [] } + }) + return { + target, + control: host === 'local' ? ssh.worktreeIds.parent! : localWorktreeId, + collapsedGroups } - }) - return { - target: target.id, - control: hostId === 'local' ? remote.id : local.id, - collapsedGroups - } - }, - { hostId, hiddenBy } - ) - const targetRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.target)}]` - ) - const controlRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.control)}]` - ) - await expect(targetRow).toHaveCount(0) - await expect(controlRow).toBeVisible() - const sendButton = orcaPage.getByRole('button', { - name: 'Send notes to an agent', - exact: true - }) - await expect(sendButton).toBeEnabled() - await orcaPage.screenshot({ - path: testInfo.outputPath('before-picker.png'), - clip: { x: 0, y: 170, width: 280, height: 500 } - }) - await sendButton.click() - await expect(orcaPage.getByText('Send notes to', { exact: true })).toBeVisible() - try { - await expect(targetRow).toBeVisible() - await expect(targetRow.locator('[data-agent-send-target="eligible"]')).toBeVisible() - } finally { - await orcaPage.screenshot({ - path: testInfo.outputPath('after-picker.png'), + }, + { host, hiddenBy, ssh, localWorktreeId } + ) + const targetRow = page.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.target)}]` + ) + const controlRow = page.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.control)}]` + ) + await expect(targetRow).toHaveCount(0) + await expect(controlRow).toBeVisible() + const sendButton = page.getByRole('button', { + name: 'Send notes to an agent', + exact: true + }) + await expect(sendButton).toBeEnabled() + await page.screenshot({ + path: testInfo.outputPath('before-picker.png'), clip: { x: 0, y: 170, width: 280, height: 500 } }) + await sendButton.click() + await expect(page.getByText('Send notes to', { exact: true })).toBeVisible() + try { + await expect(targetRow).toBeVisible() + await expect(targetRow.locator('[data-agent-send-target="eligible"]')).toBeVisible() + } finally { + await page.screenshot({ + path: testInfo.outputPath('after-picker.png'), + clip: { x: 0, y: 170, width: 280, height: 500 } + }) + } + await expect(controlRow).toBeVisible() + await expect + .poll(() => page.evaluate(() => window.__store!.getState().pendingRevealWorktree)) + .toBeNull() + await page.keyboard.press('Escape') + await expect + .poll(() => page.evaluate(() => window.__store!.getState().agentSendPopoverTargetMode)) + .toBeNull() + await expect(targetRow).toHaveCount(0) + expect(await page.evaluate(() => [...window.__store!.getState().collapsedGroups])).toEqual( + ids.collapsedGroups + ) + expect( + await page.evaluate(async () => (await window.api.ui.get()).collapsedGroups) + ).toEqual(ids.collapsedGroups) + } finally { + await launched.dispose() } - await expect(controlRow).toBeVisible() - await expect - .poll(() => orcaPage.evaluate(() => window.__store!.getState().pendingRevealWorktree)) - .toBeNull() - await orcaPage.keyboard.press('Escape') - await expect - .poll(() => orcaPage.evaluate(() => window.__store!.getState().agentSendPopoverTargetMode)) - .toBeNull() - await expect(targetRow).toHaveCount(0) - expect( - await orcaPage.evaluate(() => [...window.__store!.getState().collapsedGroups]) - ).toEqual(ids.collapsedGroups) - expect( - await orcaPage.evaluate(async () => (await window.api.ui.get()).collapsedGroups) - ).toEqual(ids.collapsedGroups) }) } } diff --git a/tests/e2e/browser-agent-input-focus-isolation.spec.ts b/tests/e2e/browser-agent-input-focus-isolation.spec.ts index 7e7f0c1b3da9..8c026f3ed608 100644 --- a/tests/e2e/browser-agent-input-focus-isolation.spec.ts +++ b/tests/e2e/browser-agent-input-focus-isolation.spec.ts @@ -10,6 +10,7 @@ import { runAgentKeysScenario, runAgentShortcutsScenario } from './helpers/browser-agent-focus-scenarios' +import { runAgentClickDuringUserCompositionScenario } from './helpers/browser-agent-composition-scenario' test('agent keys and text reach the page without leaving the userโ€™s terminal', async ({ orcaPage @@ -38,3 +39,10 @@ test('agent keys and text reach a focused cross-site frame without moving the us test.setTimeout(120_000) await runAgentCrossSiteFrameScenario(orcaPage) }) + +test('an agent click is refused while the user composes with an IME in the terminal', async ({ + orcaPage +}) => { + test.setTimeout(120_000) + await runAgentClickDuringUserCompositionScenario(orcaPage) +}) diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index 9b1dc2306e2c..ad56b6c76cc4 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -20,7 +20,6 @@ import type { AgentJournalItemBody } from '../../src/shared/agent-session-journa import { parseAgentJournalItemKey } from '../../src/shared/agent-session-journal-item-key' import type { Tab } from '../../src/shared/tab-types' import type { AppState } from '../../src/renderer/src/store/types' -import type * as WorktreeRuntimeOwnerModule from '../../src/renderer/src/lib/worktree-runtime-owner' import { createCodexJournalTranslator } from '../../src/main/codex/codex-structured-journal-translation' import { CODEX_COMMAND_APPROVAL_METHOD } from '../../src/main/codex/codex-structured-prompt-replies' import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' @@ -59,13 +58,6 @@ vi.mock('@/store', async () => { return { useAppStore } }) -// Partial: the status projection also resolves each chat's owner from the worktree. -vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({ - ...(await importOriginal()), - getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => - state.testRuntimeOwner ?? null -})) - vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: vi.fn(), subscribeStructuredAgentSession: vi.fn(), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 89353b5ec6d3..18b834508b16 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -76,6 +76,7 @@ export function structuredHostStub( value: { itemId: 'item-1', epoch: 'rewound-epoch' } })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [] })), revealSession: vi.fn(async () => ({ sessionId, workspaceId, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index 5c04b8c30ef2..d9b1352c7b5b 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -79,6 +79,11 @@ export const STRUCTURED_CALLS: { result: { ok: true, replayed: false, value: { itemId: 'item-1', epoch: 'rewound-epoch' } } }, { method: 'agentSession.close', hostMethod: 'close', result: { ok: true } }, + { + method: 'agentSession.sleepWorkspace', + hostMethod: 'sleepWorkspace', + result: { stoppedSessionIds: [] } + }, { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt', @@ -210,5 +215,12 @@ export const STRUCTURED_CALLS: { method: 'agentSession.agents', hostMethod: null, result: { agents: expect.arrayContaining([expect.objectContaining({ agent: 'codex' })]) } + }, + // Answered from the runtime's shared availability resource, not the host, so the stub's first + // generation is the only signal that the gate opened. + { + method: 'agentSession.subscribeAvailability', + hostMethod: null, + result: { type: 'availability', agents: [] } } ] diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 36eb0b4cef52..0c303be1f4ad 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -30,6 +30,10 @@ export function runtimeStub(overrides: Record = {}): unknown { return resolved }, publishStructuredAgentSessionTab: () => {}, + subscribeStructuredAgentAvailability: (read: (agents: unknown[]) => void) => { + read([]) + return () => {} + }, registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), cleanupSubscription: subscriptions.cleanup.bind(subscriptions), diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 1cf4c790b435..5ad14f950f1f 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -178,6 +178,8 @@ export function paramsFor(method: string): unknown { } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } + case 'agentSession.sleepWorkspace': + return { workspaceId: WORKSPACE } case 'agentSession.currentRequests': return { sessionId: SESSION, version: '00000000-0000-4000-8000-000000000001' } case 'agentSession.acknowledgeAttention': @@ -190,6 +192,7 @@ export function paramsFor(method: string): unknown { case 'agentSession.release': return { sessionId: SESSION, holderId: 'surface-1' } case 'agentSession.agents': + case 'agentSession.subscribeAvailability': case 'agentSession.restartResumable': case 'agentSession.restartResumableDismiss': case 'agentSession.restartResume': diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts index 166dc419431a..21c3794bba47 100644 --- a/tests/e2e/default-branch-visibility.spec.ts +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -4,9 +4,10 @@ * while a sleeping feature workspace is still swept. */ -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' +import { waitForSessionReady } from './helpers/store' import { worktreeRow } from './worktree-row-locators' type SidebarVisibilityScenario = { @@ -14,92 +15,45 @@ type SidebarVisibilityScenario = { featureId: string } -async function seedSidebarVisibilityScenario(page: Page): Promise { - return page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Sidebar visibility E2E needs a seeded repo') - } - - const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0] - if (!currentWorktree) { - throw new Error('Sidebar visibility E2E needs a seeded worktree') - } - - const defaultBranchId = 'e2e-default-branch-visibility-main' - const featureId = 'e2e-default-branch-visibility-feature' - const currentId = currentWorktree.id - - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: [ - { - ...currentWorktree, - id: currentId, - displayName: 'Current workspace', - isMainWorktree: false, - branch: 'refs/heads/current', - lastActivityAt: 3 - }, - { - ...currentWorktree, - id: defaultBranchId, - displayName: 'Default branch workspace', - isMainWorktree: true, - branch: 'refs/heads/main', - lastActivityAt: 2 - }, - { - ...currentWorktree, - id: featureId, - displayName: 'Feature workspace', - isMainWorktree: false, - branch: 'refs/heads/feature', - lastActivityAt: 1 - } - ] - }, - tabsByWorktree: { - ...current.tabsByWorktree, - [defaultBranchId]: [], - [featureId]: [] - }, - browserTabsByWorktree: { - ...current.browserTabsByWorktree, - [defaultBranchId]: [], - [featureId]: [] - } - })) - - const nextState = store.getState() - nextState.setActiveView('terminal') - nextState.setSidebarOpen(true) - nextState.setGroupBy('none') - nextState.setSortBy('recent') - nextState.setShowActiveOnly(false) - nextState.setFilterRepoIds([]) - - return { defaultBranchId, featureId } - }) +async function seedSidebarVisibilityScenario( + page: Page, + testInfo: TestInfo +): Promise { + // The repo's own checkout is its default branch workspace. + const repo = await addRepoWithWorkspaces(page, testInfo, 2) + const [currentId, featureId] = repo.ids + await writeWorkspaceMeta(page, [ + { worktreeId: currentId!, updates: { displayName: 'Current workspace', lastActivityAt: 3 } }, + { + worktreeId: repo.mainId, + updates: { displayName: 'Default branch workspace', lastActivityAt: 2 } + }, + { worktreeId: featureId!, updates: { displayName: 'Feature workspace', lastActivityAt: 1 } } + ]) + await page.evaluate((currentId) => { + const state = window.__store!.getState() + state.setActiveWorktree(currentId) + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('none') + state.setSortBy('recent') + state.setShowActiveOnly(false) + state.setFilterRepoIds([]) + }, currentId!) + return { defaultBranchId: repo.mainId, featureId: featureId! } } +test.use({ seedTestRepo: false }) + test.describe('Default branch visibility', () => { test.beforeEach(async ({ orcaPage }) => { await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) }) test('keeps the default branch visible when sleeping workspaces are hidden', async ({ orcaPage - }) => { - const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage) + }, testInfo) => { + const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage, testInfo) const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId) const featureRow = worktreeRow(orcaPage, featureId) diff --git a/tests/e2e/editor-model-host-isolation.spec.ts b/tests/e2e/editor-model-host-isolation.spec.ts index 5c201a0ba540..b4fb8d838137 100644 --- a/tests/e2e/editor-model-host-isolation.spec.ts +++ b/tests/e2e/editor-model-host-isolation.spec.ts @@ -18,21 +18,15 @@ test('keeps same-path host models and undo history separate', async ({ registerPostElectronShutdownCleanup(() => rm(filePath, { force: true })) const remoteId = buildOwnedEditorFileId(filePath, context.worktreeId, 'host-model-fixture') - // Controlled owner records exercise renderer identity; file transport remains local. - await orcaPage.evaluate( - ({ filePath, remoteId, worktreeId }) => { + // Both tabs open through the user's open path, so a layout-owner build's main holds them too. + // The second owner is keyed apart by a runtime id, then its record is pointed back at the + // fixture's SSH route with local transport before it first loads (it opens unselected). + const openedIds = await orcaPage.evaluate( + ({ filePath, worktreeId }) => { const store = window.__store if (!store) { throw new Error('Editor store unavailable') } - const base = { - filePath, - relativePath: filePath.split(/[\\/]/).pop() ?? filePath, - worktreeId, - language: 'plaintext', - mode: 'edit' as const, - isDirty: true - } const generation = { runtimeConnectionGeneration: null, runtimePairingRevision: undefined, @@ -40,49 +34,58 @@ test('keeps same-path host models and undo history separate', async ({ nestedSshGeneration: null, directSshGeneration: null } + const provenance = ( + executionHostId: 'local' | 'ssh:fixture-target', + runtimeEnvironmentId: string | null + ) => ({ + ownershipProjection: 'explicit' as const, + generation: { ...generation, route: { executionHostId, runtimeEnvironmentId } } + }) + const file = { + filePath, + relativePath: filePath.split(/[\\/]/).pop() ?? filePath, + worktreeId, + language: 'plaintext', + mode: 'edit' as const + } + const localId = store.getState().openFile({ + ...file, + runtimeEnvironmentId: null, + operationProvenance: provenance('local', null) + }) + const remoteId = store.getState().openFile( + { + ...file, + runtimeEnvironmentId: 'host-model-fixture', + operationProvenance: provenance('ssh:fixture-target', 'host-model-fixture') + }, + { selection: 'none' } + ) store.setState((state) => ({ - openFiles: [ - { - ...base, - id: filePath, - operationProvenance: { - ownershipProjection: 'explicit', - generation: { - ...generation, - route: { executionHostId: 'local', runtimeEnvironmentId: null } - } - } - }, - { - ...base, - id: remoteId, - operationProvenance: { - ownershipProjection: 'explicit', - generation: { - ...generation, - route: { executionHostId: 'ssh:fixture-target', runtimeEnvironmentId: null } + openFiles: state.openFiles.map((entry) => + entry.id === remoteId + ? { + ...entry, + isDirty: true, + runtimeEnvironmentId: undefined, + operationProvenance: provenance('ssh:fixture-target', null) } - } - } - ], + : entry.id === localId + ? { ...entry, isDirty: true } + : entry + ), editorDrafts: { ...state.editorDrafts, - [filePath]: 'LOCAL_MARKER', + [localId]: 'LOCAL_MARKER', [remoteId]: 'REMOTE_MARKER' - }, - activeFileId: filePath, - activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: filePath } + } })) - const state = store.getState() - const localTab = state.createUnifiedTab(worktreeId, 'editor', { - entityId: filePath, - label: 'Local file' - }) - state.createUnifiedTab(worktreeId, 'editor', { entityId: remoteId, label: 'Remote file' }) - store.getState().activateTab(localTab.id) + store.getState().setActiveFile(localId) + return { localId, remoteId } }, - { filePath, remoteId, worktreeId: context.worktreeId } + { filePath, worktreeId: context.worktreeId } ) + expect(openedIds).toEqual({ localId: filePath, remoteId }) const editor = orcaPage.locator('.monaco-editor').first() await expect(editor).toBeVisible({ timeout: 25_000 }) diff --git a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts index 417133b7c437..ea55aa7ff983 100644 --- a/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts +++ b/tests/e2e/folder-upgrade-identity-persistence.unit.test.ts @@ -37,7 +37,7 @@ it('retains the folder instance and metadata through upgrade, listing, and Store activeTabId: 'omp-tab', tabsByWorktree: { [id]: [makeTerminalTab({ id: 'omp-tab', worktreeId: id })] } }) - store.updateRepo(owner.id, { kind: 'git', folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.updateRepo(owner, { kind: 'git', folderUpgradeGitRootPath: 'C:/projects/draft' }) store.flush() const reloaded = createStore() const repo = reloaded.getRepo(owner.id) @@ -95,7 +95,7 @@ it('retains the folder instance and metadata through upgrade, listing, and Store it('drops upgrade path evidence when execution ownership changes', () => { const store = createStore() store.addRepo(makeRepo({ id: 'folder', path: 'C:\\projects\\draft', kind: 'git' })) - store.updateRepo('folder', { folderUpgradeGitRootPath: 'C:/projects/draft' }) - store.updateRepo('folder', { executionHostId: 'ssh:builder' }) + store.updateRepo({ id: 'folder' }, { folderUpgradeGitRootPath: 'C:/projects/draft' }) + store.updateRepo({ id: 'folder' }, { executionHostId: 'ssh:builder' }) expect(store.getRepo('folder')?.folderUpgradeGitRootPath).toBeUndefined() }) diff --git a/tests/e2e/helpers/browser-agent-composition-scenario.ts b/tests/e2e/helpers/browser-agent-composition-scenario.ts new file mode 100644 index 000000000000..fcf79a077a4f --- /dev/null +++ b/tests/e2e/helpers/browser-agent-composition-scenario.ts @@ -0,0 +1,77 @@ +/** + * An agent press while the user is mid-IME-composition in the terminal (#28700). + * Blurring the terminal then would commit the unfinished text to the shell, so the press is refused. + * See docs/reference/agent-browser-focus-isolation.md. + */ + +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import { + evalInPage, + openBackgroundProbeTab, + rpc, + startProbeServer +} from './browser-agent-focus-scenarios' +import { getTerminalContent } from './terminal-pane-identity' + +async function readCompositionLog(page: Page): Promise { + return page.evaluate(() => { + const parsed: unknown = JSON.parse(document.documentElement.dataset.compositionProbeLog ?? '[]') + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === 'string') + : [] + }) +} + +export async function runAgentClickDuringUserCompositionScenario(page: Page): Promise { + const server = await startProbeServer() + try { + const pageId = await openBackgroundProbeTab(page, server.url) + await page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('No terminal input to focus') + } + textarea.focus() + const log: string[] = [] + const record = (entry: string): void => { + log.push(entry) + document.documentElement.dataset.compositionProbeLog = JSON.stringify(log) + } + for (const type of ['compositionend', 'blur']) { + textarea.addEventListener(type, (event) => + record(event instanceof CompositionEvent ? `${type}:${event.data}` : type) + ) + } + }) + // Why: CDP enters Chromium where a real input method does: pinyin typed, no candidate picked. + const cdp = await page.context().newCDPSession(page) + await cdp.send('Input.imeSetComposition', { text: 'zhong', selectionStart: 5, selectionEnd: 5 }) + + const refused: unknown = await page.evaluate( + (id) => + window.api.runtime.call({ + method: 'browser.click', + params: { page: id, element: '#target' } + }), + pageId + ) + expect(JSON.stringify(refused)).toContain('typing with an input method') + expect(await evalInPage(page, pageId, 'window.__clicks')).toBe('0') + expect(await readCompositionLog(page)).toEqual([]) + + await cdp.send('Input.insertText', { text: 'ไธญ' }) + await rpc(page, 'browser.click', { page: pageId, element: '#target' }) + await expect.poll(() => evalInPage(page, pageId, 'window.__clicks')).toBe('1') + expect((await readCompositionLog(page))[0]).toBe('compositionend:ไธญ') + await expect + .poll(() => page.evaluate(() => document.activeElement?.className ?? '')) + .toContain('xterm-helper-textarea') + // Why: a non-UTF-8 CI shell may not echo ไธญ; a later ASCII key shows what reached it before. + await page.keyboard.type('#28700') + await expect.poll(() => getTerminalContent(page)).toContain('#28700') + expect(await getTerminalContent(page)).not.toContain('zhong') + } finally { + await server.close() + } +} diff --git a/tests/e2e/helpers/browser-agent-focus-scenarios.ts b/tests/e2e/helpers/browser-agent-focus-scenarios.ts index 634e07026406..d089853e11cc 100644 --- a/tests/e2e/helpers/browser-agent-focus-scenarios.ts +++ b/tests/e2e/helpers/browser-agent-focus-scenarios.ts @@ -76,7 +76,7 @@ const FRAME_HTML = ` ` -async function startProbeServer(): Promise<{ url: string; close: () => Promise }> { +export async function startProbeServer(): Promise<{ url: string; close: () => Promise }> { const server: Server = createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) const address = server.address() @@ -110,7 +110,11 @@ function readProperty(value: unknown, key: string): unknown { : undefined } -async function rpc(page: Page, method: string, params: Record): Promise { +export async function rpc( + page: Page, + method: string, + params: Record +): Promise { const response: unknown = await page.evaluate( ({ targetMethod, targetParams }) => window.api.runtime.call({ method: targetMethod, params: targetParams }), @@ -165,7 +169,7 @@ export async function readHost(page: Page): Promise<{ log: string[]; terminalFoc } /** Opens the probe in a browser tab, then puts the terminal back in front of it. */ -async function openBackgroundProbeTab(page: Page, url: string): Promise { +export async function openBackgroundProbeTab(page: Page, url: string): Promise { await waitForActiveWorktree(page) await ensureTerminalVisible(page) const worktreeId = await getActiveWorktreeId(page) diff --git a/tests/e2e/helpers/main-terminal-layout.ts b/tests/e2e/helpers/main-terminal-layout.ts new file mode 100644 index 000000000000..026d4792a55d --- /dev/null +++ b/tests/e2e/helpers/main-terminal-layout.ts @@ -0,0 +1,47 @@ +import type { Page } from '@stablyai/playwright-test' +import { leafIdsInOrder } from './workspace-layout-oracle-model' +import { readWorkspaceLayoutStreamFrame } from '../../../src/shared/workspace-layout/workspace-layout-stream-frames' + +/** Terminal tab id โ†’ its pane ids in tree order, in tab-bar order. */ +export type TerminalLayout = [tabId: string, paneIds: string[]][] + +/** What main holds for the workspace, read from a stream of the test's own. */ +export async function readMainLayout(page: Page, worktreeId: string): Promise { + const raw = await page.evaluate( + (id) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('main sent no layout snapshot')), 10_000) + let handle: { unsubscribe: () => void } | null = null + let snapshot: unknown + const finish = (): void => { + if (handle && snapshot !== undefined) { + clearTimeout(timer) + handle.unsubscribe() + resolve(snapshot) + } + } + void window.api.runtime + .subscribe({ method: 'layout.subscribe', params: { workspaces: [id] } }, (response) => { + if (snapshot === undefined && response.ok) { + snapshot = response.result + finish() + } + }) + .then((opened) => { + handle = opened + finish() + }, reject) + }), + worktreeId + ) + const frame = readWorkspaceLayoutStreamFrame(raw) + if (frame?.type !== 'snapshot') { + throw new Error( + `layout.subscribe opened with no snapshot: ${JSON.stringify(raw).slice(0, 300)}` + ) + } + const layout = frame.workspaces.find((entry) => entry.key === worktreeId)?.layout + return (layout?.tabs ?? []) + .filter((tab) => tab.kind === 'terminal') + .map((tab) => [tab.entityId, leafIdsInOrder(tab.terminal?.root)]) +} diff --git a/tests/e2e/helpers/page-evaluate-polled.ts b/tests/e2e/helpers/page-evaluate-polled.ts new file mode 100644 index 000000000000..8e8032d6ce75 --- /dev/null +++ b/tests/e2e/helpers/page-evaluate-polled.ts @@ -0,0 +1,43 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +type Outcome = + | { state: 'pending' } + | { state: 'done'; value: Result } + | { state: 'failed'; error: string } + +let nextKey = 0 + +/** page.evaluate whose outcome is polled: awaiting a preload promise across CDP can be garbage-collected. + * `args` cross as JSON; bulk work passes a `timeout` that grows with its size. */ +export async function evaluatePolled( + page: Page, + run: (args: Args) => Promise, + args: Args, + options: { timeout?: number } = {} +): Promise { + nextKey += 1 + const key = JSON.stringify(`__e2eEvaluatePolled${nextKey}`) + await page.evaluate(`(() => { + const run = ${run.toString()}; + globalThis[${key}] = { state: 'pending' }; + Promise.resolve() + .then(() => run(${JSON.stringify(args)})) + .then( + (value) => { globalThis[${key}] = { state: 'done', value }; }, + (error) => { globalThis[${key}] = { state: 'failed', error: String(error) }; } + ); + })()`) + const read = () => page.evaluate>(`globalThis[${key}]`) + await expect + .poll(async () => (await read()).state, { + message: 'page work never settled', + timeout: options.timeout + }) + .not.toBe('pending') + const outcome = await read() + if (outcome.state !== 'done') { + throw new Error(outcome.state === 'failed' ? outcome.error : 'page work never settled') + } + return outcome.value +} diff --git a/tests/e2e/helpers/phone-feed-list.ts b/tests/e2e/helpers/phone-feed-list.ts index d18b9421b0df..cb895e0ca604 100644 --- a/tests/e2e/helpers/phone-feed-list.ts +++ b/tests/e2e/helpers/phone-feed-list.ts @@ -156,7 +156,11 @@ export async function desktopSidebarOutline(page: Page): Promise { .filter((node) => node.id.startsWith(prefix)) .map((option) => { if (option.getAttribute('role') === 'option') { - const key = decodeURIComponent(option.id.slice(prefix.length)) + // A folder row's id is its host identity; its list key is the virtual row key. + const virtualKey = option.dataset.worktreeVirtualRowKey + const key = virtualKey?.startsWith('folder-workspace:') + ? virtualKey + : decodeURIComponent(option.id.slice(prefix.length)) return `ITEM ${key}${option.getAttribute('aria-current') === 'page' ? ' ACTIVE' : ''}` } const text = (option.innerText || option.textContent || '').replace(/\s+/g, ' ').trim() diff --git a/tests/e2e/helpers/repo-with-workspaces.ts b/tests/e2e/helpers/repo-with-workspaces.ts new file mode 100644 index 000000000000..528b8a8dc2c2 --- /dev/null +++ b/tests/e2e/helpers/repo-with-workspaces.ts @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, realpathSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import type { WorktreeMeta } from '../../../src/shared/worktree/meta-types' +import { expect } from './orca-app' +import { evaluatePolled } from './page-evaluate-polled' +import { loadWorktreesUntilPathsPresent } from './worktree-registration' + +/** Poll budget for setup that makes one main write per item. */ +export function bulkWriteTimeout(items: number): number { + return 10_000 + items * 500 +} + +export type RepoWithWorkspaces = { + repoId: string + /** The repo's own checkout, listed as its main workspace. */ + mainId: string + /** One workspace id per requested count, in order. */ + ids: string[] +} + +/** + * A fresh git repo with `count` worktrees, added through main as a user adds a project that already + * has checkouts. Its worktrees are its own, so a spec never grows the worker's shared repo. + */ +export async function addRepoWithWorkspaces( + page: Page, + testInfo: TestInfo, + count: number +): Promise { + const root = testInfo.outputPath('repo-with-workspaces') + const repoPath = path.join(root, 'repo') + mkdirSync(repoPath, { recursive: true }) + const git = (...args: string[]) => execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) + git('init', '-b', 'main') + writeFileSync(path.join(repoPath, 'README.md'), 'workspaces\n') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + const realRoot = realpathSync(root) + const worktreePaths = Array.from({ length: count }, (_, index) => { + const worktreePath = path.join(realRoot, `workspace-${index}`) + git('worktree', 'add', '-q', '-b', `workspace-${index}`, worktreePath) + return worktreePath + }) + const realRepoPath = realpathSync(repoPath) + const repoId = await evaluatePolled( + page, + async (repoPath) => { + const added = await window.api.repos.add({ path: repoPath }) + if ('error' in added) { + throw new Error(added.error) + } + return added.repo.id + }, + realRepoPath + ) + // The add's own refresh can supersede the helper's fetch, and updateRepo skips a repo the store lacks. + await expect + .poll( + () => + page.evaluate(async (repoId) => { + const store = window.__store! + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + return store.getState().repos.some((repo) => repo.id === repoId) + }, repoId), + { message: 'the added repo never reached the store' } + ) + .toBe(true) + await evaluatePolled( + page, + async (repoId) => { + // Checkouts made outside Orca's workspace root are hidden by default. + const shown = await window + .__store!.getState() + .updateRepo(repoId, { externalWorktreeVisibility: 'show' }) + if (!shown) { + throw new Error(`could not show external worktrees for ${repoId}`) + } + }, + repoId + ) + await loadWorktreesUntilPathsPresent(page, repoId, [realRepoPath, ...worktreePaths]) + const idByPath = await page.evaluate( + (repoId) => + Object.fromEntries( + (window.__store!.getState().worktreesByRepo[repoId] ?? []).map((worktree) => [ + worktree.path, + worktree.id + ]) + ), + repoId + ) + return { + repoId, + mainId: idByPath[realRepoPath]!, + ids: worktreePaths.map((worktreePath) => idByPath[worktreePath]!) + } +} + +/** Writes workspace metadata through main, all at once, and fails unless main lists it. */ +export async function writeWorkspaceMeta( + page: Page, + updates: { worktreeId: string; updates: Partial }[] +): Promise { + await evaluatePolled( + page, + async (updates) => { + // The store keeps its own copy when main refuses, so check main's listing. + await window.__store!.getState().updateWorktreesMeta(updates) + const repoIds = [...new Set(updates.map(({ worktreeId }) => worktreeId.split('::')[0]!))] + const listed = new Map( + (await Promise.all(repoIds.map((repoId) => window.api.worktrees.list({ repoId })))) + .flat() + .map((worktree) => [worktree.id, worktree]) + ) + for (const { worktreeId, updates: written } of updates) { + const worktree = listed.get(worktreeId) + if (!worktree) { + throw new Error(`main does not list ${worktreeId}`) + } + const kept = new Map(Object.entries(worktree)) + for (const [field, value] of Object.entries(written)) { + if (JSON.stringify(kept.get(field)) !== JSON.stringify(value)) { + throw new Error(`main did not keep ${field} for ${worktreeId}`) + } + } + } + }, + updates, + { timeout: bulkWriteTimeout(updates.length) } + ) +} + +/** Links each child under its parent through main, as "Set parent workspace" does. */ +export async function linkWorkspaces( + page: Page, + links: { childId: string; parentId: string }[] +): Promise { + await evaluatePolled( + page, + async (links) => { + for (const { childId, parentId } of links) { + await window + .__store!.getState() + .assignWorktreeParent(childId, { parentWorktreeId: parentId }) + } + }, + links, + { timeout: bulkWriteTimeout(links.length) } + ) +} diff --git a/tests/e2e/helpers/seeded-ssh-workspaces.ts b/tests/e2e/helpers/seeded-ssh-workspaces.ts new file mode 100644 index 000000000000..98ed6c50f304 --- /dev/null +++ b/tests/e2e/helpers/seeded-ssh-workspaces.ts @@ -0,0 +1,108 @@ +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import type { Repo } from '../../../src/shared/repo-types' +import { toSshExecutionHostId } from '../../../src/shared/execution-host' +import { expect } from './orca-app' +import { seedRelayEraTarget } from './orcad-upgrade-profile' +import { attachRepoAndOpenTerminal, createRestartSession } from './orca-restart' +import { mutateStoppedProfileState } from './persisted-profile-state' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './store' + +export type SeededSshWorkspaces = { + targetId: string + hostId: `ssh:${string}` + repoId: string + /** Each seeded workspace's id, by its name. */ + worktreeIds: Record +} + +/** + * An SSH host with one project and the workspaces main stores for it, seeded into a stopped profile. + * Nothing listens on its port, so the next launch lists exactly these stored rows. + */ +function seedUnreachableSshWorkspaces( + userDataDir: string, + input: { label: string; workspaces: { name: string; displayName: string }[] } +): SeededSshWorkspaces { + const targetId = seedRelayEraTarget(userDataDir, { + label: input.label, + host: '127.0.0.1', + port: 1, + username: 'nobody' + }) + const hostId = toSshExecutionHostId(targetId) + const repoPath = '/srv/seeded-ssh-repo' + const repo: Repo = { + id: `repo-${targetId}`, + path: repoPath, + displayName: 'Remote project', + badgeColor: '#737373', + addedAt: Date.now(), + connectionId: targetId + } + const worktreeIds = Object.fromEntries( + input.workspaces.map(({ name }) => [name, `${repo.id}::${repoPath}/${name}`]) + ) + mutateStoppedProfileState(userDataDir, (state) => { + state.repos = [...(Array.isArray(state.repos) ? state.repos : []), repo] + state.worktreeMeta = { + ...(state.worktreeMeta && typeof state.worktreeMeta === 'object' ? state.worktreeMeta : {}), + ...Object.fromEntries( + input.workspaces.map(({ name, displayName }) => [ + worktreeIds[name], + { hostId, instanceId: `${name}-instance`, displayName } + ]) + ) + } + }) + return { targetId, hostId, repoId: repo.id, worktreeIds } +} + +/** + * A desktop with the local test repo and an unreachable SSH host's seeded workspaces, both held by + * main as a user's would be. `dispose` closes the app and removes the profile. + */ +export async function launchWithUnreachableSshWorkspaces( + testInfo: TestInfo, + localRepoPath: string, + input: Parameters[1] +): Promise<{ + page: Page + ssh: SeededSshWorkspaces + /** The local test repo's primary workspace. */ + localWorktreeId: string + dispose: () => Promise +}> { + const session = createRestartSession(testInfo) + let app: ElectronApplication | undefined + try { + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + const localWorktreeId = await attachRepoAndOpenTerminal(first.page, localRepoPath) + await session.close(app) + app = undefined + const ssh = seedUnreachableSshWorkspaces(session.userDataDir, input) + const launched = await session.launch() + app = launched.app + await waitForSessionReady(launched.page) + await waitForStartupWorktreeRefresh(launched.page) + const seededIds = Object.values(ssh.worktreeIds) + await expect + .poll(() => + launched.page.evaluate((ids) => { + const listed = new Set( + Object.values(window.__store!.getState().worktreesByRepo) + .flat() + .map((worktree) => worktree.id) + ) + return ids.every((id) => listed.has(id)) + }, seededIds) + ) + .toBe(true) + const running = app + return { page: launched.page, ssh, localWorktreeId, dispose: () => session.dispose(running) } + } catch (error) { + await session.dispose(app) + throw error + } +} diff --git a/tests/e2e/helpers/terminal-inventory-observation.ts b/tests/e2e/helpers/terminal-inventory-observation.ts index 0fcefdb23c8e..3d262a43c5af 100644 --- a/tests/e2e/helpers/terminal-inventory-observation.ts +++ b/tests/e2e/helpers/terminal-inventory-observation.ts @@ -1,12 +1,22 @@ import type { RuntimeTerminalListResult } from '../../../src/shared/runtime-types' +// The host's "can't answer yet" refusals: liveness still probing, or a live PTY whose pane binding +// is mid-mount (pty-terminal-summary.ts). A poll must retry them, not end on them. +const TRANSIENT_INVENTORY_REFUSALS = [ + 'terminal_liveness_unavailable', + 'terminal_surface_ownership_unavailable' +] + export async function readFreshTerminalInventory( read: () => Promise ): Promise { try { return await read() } catch (error) { - if (error instanceof Error && error.message.includes('terminal_liveness_unavailable')) { + if ( + error instanceof Error && + TRANSIENT_INVENTORY_REFUSALS.some((refusal) => error.message.includes(refusal)) + ) { return null } throw error diff --git a/tests/e2e/helpers/terminal-inventory-observation.unit.test.ts b/tests/e2e/helpers/terminal-inventory-observation.unit.test.ts new file mode 100644 index 000000000000..8b18b53def33 --- /dev/null +++ b/tests/e2e/helpers/terminal-inventory-observation.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeTerminalListResult } from '../../../src/shared/runtime-types' +import { readFreshTerminalInventory } from './terminal-inventory-observation' + +const listed: RuntimeTerminalListResult = { terminals: [], totalCount: 0, truncated: false } + +describe('readFreshTerminalInventory', () => { + it('returns the inventory the host answered with', async () => { + await expect(readFreshTerminalInventory(async () => listed)).resolves.toBe(listed) + }) + + // A first-mount adoption refuses while the PTY's pane binding is in flight; a throw out of an + // expect.poll callback ends the poll instead of retrying it (CI run 39256965951). + it.each(['terminal_liveness_unavailable', 'terminal_surface_ownership_unavailable'])( + 'treats %s as no observation yet', + async (refusal) => { + await expect( + readFreshTerminalInventory(async () => { + throw new Error(refusal) + }) + ).resolves.toBeNull() + } + ) + + it('rethrows any other failure', async () => { + await expect( + readFreshTerminalInventory(async () => { + throw new Error('terminal_handle_stale') + }) + ).rejects.toThrow('terminal_handle_stale') + }) +}) diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts index 81023dce6699..460cdbe237ac 100644 --- a/tests/e2e/helpers/terminal-pane-operations.ts +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -4,6 +4,7 @@ import { readPaneIdentitySnapshot, resolveActiveTabId } from './terminal-pane-identity' +import { REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } from '../../../src/renderer/src/constants/terminal' export async function readTerminalPaneDomLeafOrder(page: Page): Promise { const snapshot = await readPaneIdentitySnapshot(page) @@ -125,25 +126,53 @@ export async function closeActiveTerminalPane(page: Page): Promise { if (!tabId) { throw new Error('closeActiveTerminalPane: no active terminal tab') } - await page.evaluate((tabId) => { - const paneManagers = window.__paneManagers - if (!paneManagers) { - throw new Error('closeActiveTerminalPane: terminal store/manager unavailable') - } - - const manager = paneManagers.get(tabId) - const panes = manager?.getPanes?.() ?? [] - if (!manager?.closePane || panes.length < 2) { - return - } - - const activePane = manager.getActivePane?.() ?? panes[0] - if (!activePane) { - return - } + await requestTerminalPaneClose(page, tabId, 'active') +} - manager.closePane(activePane.id) - }, tabId) +/** + * Closes one of a tab's split panes the way the user's close does (pane menu, shortcut), then waits + * for it to go. A bare `PaneManager.closePane` skips the close command main applies first in a + * layout-owner build, so main keeps a pane whose terminal had not attached yet. + */ +export async function requestTerminalPaneClose( + page: Page, + tabId: string, + pane: 'active' | 'last' +): Promise { + const leafId = await page.evaluate( + ({ tabId, pane, closeEvent }) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error(`requestTerminalPaneClose: no mounted pane manager for tab ${tabId}`) + } + const panes = manager.getPanes() + const target = pane === 'last' ? panes.at(-1) : (manager.getActivePane() ?? panes[0]) + if (!target || panes.length < 2) { + return null + } + manager.setActivePane(target.id, { focus: false }) + window.dispatchEvent(new CustomEvent(closeEvent, { detail: { tabId } })) + return target.leafId + }, + { tabId, pane, closeEvent: REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } + ) + if (!leafId) { + return + } + await expect + .poll( + () => + page.evaluate( + ({ tabId, leafId }) => + window.__paneManagers + ?.get(tabId) + ?.getPanes() + .some((candidate) => candidate.leafId === leafId) ?? false, + { tabId, leafId } + ), + { timeout: 15_000, message: `pane ${leafId} did not close` } + ) + .toBe(false) } export async function focusLastTerminalPane(page: Page): Promise { diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index 7b7c83d2c870..5b37627c7c65 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -25,6 +25,8 @@ import { waitForTerminalOutput as waitForTerminalOutputImpl } from './terminal-pane-operations' +export { requestTerminalPaneClose } from './terminal-pane-operations' + export { expectTerminalAccessibilityText, readTerminalAccessibilityText diff --git a/tests/e2e/layout-stream-loss-reopen.spec.ts b/tests/e2e/layout-stream-loss-reopen.spec.ts index 4cedd5e29cb6..10bb10414f63 100644 --- a/tests/e2e/layout-stream-loss-reopen.spec.ts +++ b/tests/e2e/layout-stream-loss-reopen.spec.ts @@ -12,15 +12,12 @@ import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca- import { ensureTerminalVisible, switchToWorktree, waitForSessionReady } from './helpers/store' import { splitActiveTerminalPane, waitForActiveTerminalManager } from './helpers/terminal' import { leafIdsInOrder } from './helpers/workspace-layout-oracle-model' -import { readWorkspaceLayoutStreamFrame } from '../../src/shared/workspace-layout/workspace-layout-stream-frames' +import { readMainLayout, type TerminalLayout } from './helpers/main-terminal-layout' const LAYOUT_OWNER_BUILD = existsSync( path.join(process.cwd(), 'out', 'main', 'ORCA_LAYOUT_OWNER_MAIN') ) -/** Terminal tab id โ†’ its pane ids in tree order, in tab-bar order. */ -type TerminalLayout = [tabId: string, paneIds: string[]][] - type Launched = Awaited['launch']>> /** What the window draws for the workspace's terminal tabs. */ @@ -48,47 +45,6 @@ async function readWindowLayout(page: Page, worktreeId: string): Promise [tabId, leafIdsInOrder(drawn.roots[tabId])]) } -/** What main holds for the workspace, read from a stream of the test's own. */ -async function readMainLayout(page: Page, worktreeId: string): Promise { - const raw = await page.evaluate( - (id) => - new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('main sent no layout snapshot')), 10_000) - let handle: { unsubscribe: () => void } | null = null - let snapshot: unknown - const finish = (): void => { - if (handle && snapshot !== undefined) { - clearTimeout(timer) - handle.unsubscribe() - resolve(snapshot) - } - } - void window.api.runtime - .subscribe({ method: 'layout.subscribe', params: { workspaces: [id] } }, (response) => { - if (snapshot === undefined && response.ok) { - snapshot = response.result - finish() - } - }) - .then((opened) => { - handle = opened - finish() - }, reject) - }), - worktreeId - ) - const frame = readWorkspaceLayoutStreamFrame(raw) - if (frame?.type !== 'snapshot') { - throw new Error( - `layout.subscribe opened with no snapshot: ${JSON.stringify(raw).slice(0, 300)}` - ) - } - const layout = frame.workspaces.find((entry) => entry.key === worktreeId)?.layout - return (layout?.tabs ?? []) - .filter((tab) => tab.kind === 'terminal') - .map((tab) => [tab.entityId, leafIdsInOrder(tab.terminal?.root)]) -} - /** Tab ids in tab-bar order and each tab's pane count: what a lost or doubled tab or pane changes. */ type Summary = { tabs: string[]; panes: number[] } diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index 658234428377..dc48a7cbb535 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -7,6 +7,7 @@ import type { Page } from '@stablyai/playwright-test' import { test as base, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { readFreshTerminalInventory } from './helpers/terminal-inventory-observation' import { clearTerminalPtyWriteLog, installTerminalPtyWriteSpy, @@ -131,16 +132,13 @@ function createSourceRepo(): string { return repoPath } -async function readWorktreeTerminals( - client: RuntimeClient, - worktreeId: string -): Promise { - const listed = await client.call('terminal.list', { - worktree: `id:${worktreeId}`, - limit: 20, - requireFreshPtyLiveness: true - }) - return listed.result.terminals +// Undefined while first-mount adoption has the host refusing to answer; every caller polls past it. +async function readWorktreeTerminals(client: RuntimeClient, worktreeId: string) { + const params = { worktree: `id:${worktreeId}`, limit: 20, requireFreshPtyLiveness: true } + const listed = await readFreshTerminalInventory( + async () => (await client.call('terminal.list', params)).result + ) + return listed?.terminals .filter((terminal) => terminal.worktreeId === worktreeId) .sort((a, b) => a.handle.localeCompare(b.handle)) } @@ -327,9 +325,12 @@ async function assertLiveInventory( originals: RuntimeTerminalSummary[] ): Promise { await expect - .poll(async () => (await readWorktreeTerminals(client, worktreeId)).map(liveTerminalIdentity), { - timeout: 15_000 - }) + .poll( + async () => (await readWorktreeTerminals(client, worktreeId))?.map(liveTerminalIdentity), + { + timeout: 15_000 + } + ) .toEqual(originals.map(liveTerminalIdentity)) } @@ -584,7 +585,7 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ let originals: RuntimeTerminalSummary[] = [] await expect .poll(async () => { - originals = await readWorktreeTerminals(client, worktreeId) + originals = (await readWorktreeTerminals(client, worktreeId)) ?? [] return originals.map(({ connected, writable }) => ({ connected, writable })) }) .toEqual([ @@ -644,7 +645,7 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ .poll( async () => ({ mountedPtyId: mountedAgentPtyId, - liveInventory: (await readWorktreeTerminals(client, worktreeId)).map(liveTerminalIdentity), + liveInventory: (await readWorktreeTerminals(client, worktreeId))?.map(liveTerminalIdentity), visibleOriginalReady: ( await terminalAccessibility(orcaPage, agent!.tabId).innerText() ).includes(`LIVE_AGENT_READY:${agentPid}`), diff --git a/tests/e2e/native-chat-ssh-delivery.unit.test.ts b/tests/e2e/native-chat-ssh-delivery.unit.test.ts index 806007eaf671..72e62d9ed96c 100644 --- a/tests/e2e/native-chat-ssh-delivery.unit.test.ts +++ b/tests/e2e/native-chat-ssh-delivery.unit.test.ts @@ -13,7 +13,7 @@ import { resetNativeChatPtySendQueuesForTests } from '../../src/renderer/src/components/native-chat/native-chat-runtime-send' import { buildAskAnswerKeys } from '../../src/shared/native-chat-ask' -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-verified-input' import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher' import { TERMINAL_METHODS } from '../../src/main/runtime/rpc/methods/terminal' import { sendAgentDraftPasteContentNow } from '../../src/renderer/src/lib/agent-draft-paste-content' diff --git a/tests/e2e/sidebar-unread-older-paired-host.spec.ts b/tests/e2e/sidebar-unread-older-paired-host.spec.ts new file mode 100644 index 000000000000..4cec2ea0dd3c --- /dev/null +++ b/tests/e2e/sidebar-unread-older-paired-host.spec.ts @@ -0,0 +1,144 @@ +/** + * A desktop paired to an `orca serve` host raises a workspace's unread flag itself exactly when that + * host does not: an older host (played by withholding its capability) gets the window's flag, and a + * current host is left to raise its own. + */ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build' + +test.skip( + !SIDEBAR_INPUTS_BUILD, + 'the window defers the unread flag to its host only in an ORCA_SIDEBAR_INPUTS=main build' +) + +const ReposSchema = z.object({ + repos: z.array(z.looseObject({ id: z.string(), path: z.string() })) +}) +const WorktreeSchema = z.object({ + worktree: z.looseObject({ id: z.string(), path: z.string(), isUnread: z.boolean().optional() }) +}) + +async function launchHost(olderHost: boolean): Promise { + // The serve process copies this env at launch; the desktop launched after must not see it. + if (olderHost) { + process.env.ORCA_E2E_OLDER_UNREAD_HOST = '1' + } + try { + return await launchHeadlessPairedRuntimeHost({ pairingScope: 'runtime' }) + } finally { + delete process.env.ORCA_E2E_OLDER_UNREAD_HOST + } +} + +async function createServerWorktree( + host: HeadlessPairedRuntimeHost, + testInfo: TestInfo +): Promise { + const serverRepo = testInfo.outputPath('server-repo') + mkdirSync(serverRepo, { recursive: true }) + writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n') + const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' }) + git('init', '-b', 'main') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + await host.client.call('repo.add', { path: serverRepo, kind: 'git' }) + const { repos } = ReposSchema.parse((await host.client.call('repo.list', {})).result) + const repo = `id:${repos.find((entry) => entry.path === serverRepo)?.id ?? ''}` + const created = await host.client.call('worktree.create', { + repo, + name: 'flagged', + setupDecision: 'skip' + }) + return WorktreeSchema.parse(created.result).worktree.id +} + +async function hostFlag(host: HeadlessPairedRuntimeHost, worktreeId: string): Promise { + const shown = await host.client.call('worktree.show', { worktree: `id:${worktreeId}` }) + return WorktreeSchema.parse(shown.result).worktree.isUnread === true +} + +/** The window's own decision for a finish it saw while its user was elsewhere. */ +async function windowMarksUnread(page: Page, worktreeId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (id) => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .some((worktree) => worktree.id === id), + worktreeId + ), + { timeout: 60_000 } + ) + .toBe(true) + await page.evaluate((id) => { + window.__store?.getState().markAgentAttentionUnread({ + workspaceId: id, + subjectKey: null, + groupId: null, + reason: 'agent-completion' + }) + }, worktreeId) +} + +test('the window raises and clears the flag on an older paired host that raises none', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +{}, testInfo) => { + test.setTimeout(240_000) + const host = await launchHost(true) + try { + const worktreeId = await createServerWorktree(host, testInfo) + const desktop = await launchPairedElectronClient(host.offer, testInfo, 'older-host-desktop') + try { + await windowMarksUnread(desktop.page, worktreeId) + await expect.poll(() => hostFlag(host, worktreeId), { timeout: 30_000 }).toBe(true) + + // Mark as read writes through to the older host as well. + await desktop.page.evaluate((id) => { + const state = window.__store!.getState() + const row = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === id) + if (!row?.hostId) { + throw new Error('server workspace row has no host') + } + state.setWorkspaceUnreadByUser(id, false, row.hostId) + }, worktreeId) + await expect.poll(() => hostFlag(host, worktreeId), { timeout: 30_000 }).toBe(false) + } finally { + await desktop.dispose() + } + } finally { + await host.dispose() + } +}) + +test('the window leaves the flag to a paired host that raises it', async (// oxlint-disable-next-line no-empty-pattern -- this test owns its topology launch. +{}, testInfo) => { + test.setTimeout(240_000) + const host = await launchHost(false) + try { + const worktreeId = await createServerWorktree(host, testInfo) + const desktop = await launchPairedElectronClient(host.offer, testInfo, 'current-host-desktop') + try { + await windowMarksUnread(desktop.page, worktreeId) + // Nothing on the host saw news, so its flag stays down and the window wrote none. + await desktop.page.waitForTimeout(3_000) + expect(await hostFlag(host, worktreeId)).toBe(false) + } finally { + await desktop.dispose() + } + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts index abc886fa133c..e0f023c2c3d1 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -512,3 +512,113 @@ test('a relay terminal in a project another desktop already moved keeps working await desktopB.dispose() } }) + +test('a bridged tab closed while disconnected stops its shell even after a restart', async ({ + testRepoPath +}, testInfo: TestInfo) => { + test.setTimeout(25 * 60_000) + host = startDockerSshHostTarget(testInfo) + const targetId = `ssh-bridge-close-${Date.now()}` + const relay = await installReleasedRelayOnDockerHost(host, TEMPLATE_SOURCE!) + const ours = await startDockerRelayWithShell(host, relay, targetId, 'before-upgrade') + const theirs = await startDockerRelayWithShell(host, relay, 'another-desktop', 'theirs') + const bridgedPtyId = toAppSshPtyId(targetId, ours.relayPtyId) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) + const ourLease = (): { state?: unknown; pendingKill?: unknown } | undefined => { + const leases = readPersistedProfileState(session.userDataDir).sshRemotePtyLeases + return (Array.isArray(leases) ? leases : []).find( + (lease: { ptyId?: unknown }) => lease.ptyId === ours.relayPtyId + ) + } + let app: ElectronApplication | null = null + try { + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await first.page.evaluate((repoPath) => window.api.repos.add({ path: repoPath }), testRepoPath) + await session.close(app) + app = null + const seeded = seedRelayEraProfile( + session.userDataDir, + { + label: `legacy relay bridge close E2E ${Date.now()}`, + host: host.host, + port: host.port, + username: 'root', + identityFile: host.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + }, + { repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' }, + { targetId, relayPtyId: ours.relayPtyId } + ) + + const upgraded = await launchUpgraded(session) + app = upgraded.app + let page = upgraded.page + await waitForSessionReady(page) + await page.evaluate( + ({ worktreeId, hostId, tabId }) => { + const state = window.__store!.getState() + state.setActiveWorktree(worktreeId, hostId) + state.setActiveTab(tabId) + state.setActiveTabType('terminal', worktreeId) + }, + { + worktreeId: seeded.worktreeId, + hostId: toSshExecutionHostId(targetId), + tabId: seeded.terminalTabId! + } + ) + await expect + .poll(() => managedServer(page, targetId), { timeout: 8 * 60_000 }) + .toMatchObject({ kind: 'managed' }) + await expect + .poll(() => bridgedPanes(page), { timeout: BRIDGE_TIMEOUT_MS }) + .toEqual([expect.objectContaining({ ptyId: bridgedPtyId, state: 'live' })]) + + // Disconnected, the tab closes through both of its routes: the renderer's PTY kill, which + // finds no SSH provider, and then the bridge transport's own close. + await page.evaluate((id) => window.api.ssh.disconnect({ targetId: id }), targetId) + await page.evaluate(async (ptyId) => { + await window.api.pty.kill(ptyId) + await window.api.legacyRelayBridge?.close({ ptyId }) + }, bridgedPtyId) + // Loss of contact is never exit: the shell still runs, and its stop is owed. + expect(await listDockerRelayPtys(host, relay, ours.sockPath)).toEqual([ours.relayPtyId]) + + // Restart before reconnecting: only the persisted stop survives. + await session.close(app) + app = null + expect(ourLease()?.state).not.toBe('terminated') + expect(ourLease()?.pendingKill).toBeTruthy() + + const restarted = await launchUpgraded(session) + app = restarted.app + page = restarted.page + await waitForSessionReady(page) + await page.evaluate((id) => window.api.ssh.connect({ targetId: id }), targetId) + // A failed listing is not absence: only the relay's own empty answer proves the shell stopped. + await expect + .poll(() => listDockerRelayPtys(host!, relay, ours.sockPath).catch(() => ['']), { + timeout: BRIDGE_TIMEOUT_MS, + intervals: [2_000] + }) + .toEqual([]) + // Never offered back as a pane to reattach. + expect(await bridgedPanes(page)).toEqual([]) + await session.close(app) + app = null + expect(ourLease()?.pendingKill).toBeUndefined() + + // Another desktop's relay keeps running its shell, untouched. + expect(await echoThroughDockerRelay(host, relay, theirs, 'still-here')).toContain( + 'GOT:still-here' + ) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + } +}) diff --git a/tests/e2e/ssh-orcad-phone-feed-list.spec.ts b/tests/e2e/ssh-orcad-phone-feed-list.spec.ts new file mode 100644 index 000000000000..de98ac96c5e7 --- /dev/null +++ b/tests/e2e/ssh-orcad-phone-feed-list.spec.ts @@ -0,0 +1,74 @@ +/** + * A phone paired with a desktop that has a workspace on a managed SSH host lists that workspace + * from the host's sidebar feed, row for row as the desktop sidebar draws it: the SSH host's section, + * its workspace, and the single highlight while it is the open workspace. + * + * The phone side is the page's own feed reader and list builder (helpers/phone-feed-list.ts) over a + * real paired socket; the desktop side is the sidebar's rendered rows. + */ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + launchManagedWorkspace, + skipUnlessManagedDockerHost, + withManagedHost +} from './helpers/orcad-managed-workspace' +import { createDesktopMobilePairingOffer } from './helpers/paired-electron-client' +import { pairMobileClient } from './helpers/paired-mobile-client' +import { + desktopSidebarOutline, + openPhoneFeedList, + type ListOutline +} from './helpers/phone-feed-list' +import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build' + +skipUnlessManagedDockerHost() +test.skip( + !SIDEBAR_INPUTS_BUILD, + 'the phone reads the feed only in an ORCA_SIDEBAR_INPUTS=main build' +) + +type PhoneList = Awaited> + +/** Both sides inside one poll: either may still be catching up with the last change. */ +async function expectPhoneMatchesDesktop(page: Page, phone: PhoneList): Promise { + let desktop: ListOutline = [] + let shown: ListOutline = [] + await expect + .poll( + async () => { + desktop = await desktopSidebarOutline(page) + shown = await phone.outline() + return shown.join('\n') === desktop.join('\n') + }, + { timeout: 60_000 } + ) + .toBe(true) + .catch(() => expect(shown).toEqual(desktop)) + return shown +} + +test("the phone lists the desktop's managed SSH workspace as the desktop sidebar does", async (// oxlint-disable-next-line no-empty-pattern -- This exercise owns its app launch. +{}, testInfo) => { + test.setTimeout(12 * 60_000) + await withManagedHost(testInfo, async (fixture) => { + const { page, seeded } = await launchManagedWorkspace(fixture) + const sshRow = (outline: ListOutline): string[] => + outline.filter((line) => line.startsWith('ITEM ') && line.includes(seeded.repoPath)) + // The desktop draws the SSH workspace before the phone is asked to match it. + await expect + .poll(async () => sshRow(await desktopSidebarOutline(page)), { timeout: 120_000 }) + .toHaveLength(1) + + const phone = pairMobileClient(await createDesktopMobilePairingOffer(page)) + const list = await openPhoneFeedList(phone) + try { + const shown = await expectPhoneMatchesDesktop(page, list) + expect(sshRow(shown)).toEqual([expect.stringMatching(/ ACTIVE$/)]) + expect(shown.filter((line) => line.endsWith(' ACTIVE'))).toHaveLength(1) + } finally { + list.close() + phone.dispose() + } + }) +}) diff --git a/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts b/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts index 5a2eccc89a2d..3a9676e77733 100644 --- a/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts +++ b/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import path from 'node:path' import { promisify } from 'node:util' import type { Page } from '@stablyai/playwright-test' @@ -138,3 +138,69 @@ test('a CLI split whose tab closes before it mounts answers promptly', async ({ expect(outcome.ok ? '' : outcome.error).toContain('split_pane_closed') expect(elapsedMs).toBeLessThan(6_000) }) + +// The window is too busy to start the split's terminal within the CLI's 10 s: main takes the +// pane back, so the CLI's failure is the truth and the startup command never runs. +test('a CLI split whose terminal does not start within 10 s leaves no pane', async ({ + electronApp, + orcaPage +}, testInfo) => { + test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only') + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const client = new RuntimeClient(userDataDir, 30_000) + const tabId = await createActiveTab(orcaPage, worktreeId) + const source = await handleFor(client, worktreeId, tabId) + await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS }) + await orcaPage.evaluate(() => { + window.addEventListener( + 'orca-background-mount-terminal-worktree', + () => { + const until = performance.now() + 11_000 + while (performance.now() < until) { + // Busy renderer. + } + }, + { once: true, capture: true } + ) + }) + // A file the command writes: a terminal that ran it and was then killed still leaves it. + const marker = testInfo.outputPath(`split-ran-${Date.now()}`) + mkdirSync(path.dirname(marker), { recursive: true }) + const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`) + // Past the stall: the window has handled the split and main's take-back. + await new Promise((resolve) => setTimeout(resolve, 5_000)) + const tabTerminals = await client + .call('terminal.list', { worktree: `id:${worktreeId}`, limit: 200 }) + .then((listed) => listed.result.terminals.filter((t) => t.tabId === tabId)) + const windowLeafIds = await orcaPage.evaluate((id) => { + const collect = (node: unknown): string[] => { + if (typeof node !== 'object' || node === null) { + return [] + } + if ('leafId' in node && typeof node.leafId === 'string') { + return [node.leafId] + } + return [ + ...('first' in node ? collect(node.first) : []), + ...('second' in node ? collect(node.second) : []) + ] + } + return collect(window.__store?.getState().terminalLayoutsByTabId[id]?.root) + }, tabId) + const report = { outcome, tabTerminals: tabTerminals.map((t) => t.leafId), windowLeafIds } + await testInfo.attach('split-taken-back.json', { + body: JSON.stringify(report, null, 2), + contentType: 'application/json' + }) + expect(outcome.ok ? '' : outcome.error).toContain( + "split_pane_not_started: the new pane's terminal did not start within 10 s, so the pane was closed and its command was not run" + ) + expect(tabTerminals).toHaveLength(1) + expect(windowLeafIds).toEqual([tabTerminals[0]!.leafId]) + expect(existsSync(marker)).toBe(false) +}) diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts index cde78f85c576..eea50b2fff92 100644 --- a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -7,12 +7,15 @@ import { waitForSessionReady } from './helpers/store' import { + requestTerminalPaneClose, sendToTerminal, splitActiveTerminalPane, waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal' import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { readMainLayout } from './helpers/main-terminal-layout' +import { REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } from '../../src/renderer/src/constants/terminal' /** * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY @@ -159,18 +162,21 @@ async function expectLayoutConsistent( }) } -async function closeLastPaneOnTab(page: Page, tabId: string): Promise { - await page.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - if (!manager) { - throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`) - } - const target = manager.getPanes().at(-1) - if (!target) { - throw new Error('closeLastPaneOnTab: tab has no panes') - } - manager.closePane(target.id) - }, tabId) +/** Main's stored panes for the tab are the window's: the close the window drew reached main. */ +async function expectMainHoldsWindowPanes( + page: Page, + worktreeId: string, + tabId: string, + phase: string +): Promise { + const { rootLeafIds } = await readLayoutConsistency(page, tabId) + await expect + .poll( + async () => + (await readMainLayout(page, worktreeId)).find(([id]) => id === tabId)?.[1] ?? null, + { timeout: 15_000, message: `[${phase}] main's stored panes differ from the window's` } + ) + .toEqual(rootLeafIds) } async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { @@ -254,15 +260,16 @@ async function setUpSplitTab(page: Page): Promise { test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => { test('control: close while visible', async ({ orcaPage }) => { - const { tabId } = await setUpSplitTab(orcaPage) - await closeLastPaneOnTab(orcaPage, tabId) + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await requestTerminalPaneClose(orcaPage, tabId, 'last') await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible') + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'close-visible') }) test('close and hide the tab in the same tick', async ({ orcaPage }) => { const { worktreeId, tabId } = await setUpSplitTab(orcaPage) await orcaPage.evaluate( - ({ tabId, worktreeId }) => { + ({ tabId, worktreeId, closeEvent }) => { const store = window.__store const manager = window.__paneManagers?.get(tabId) if (!store || !manager) { @@ -272,31 +279,35 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist if (!target) { throw new Error('close+hide: no split pane') } - manager.closePane(target.id) + // The user's close (see requestTerminalPaneClose), sent without waiting for it. + manager.setActivePane(target.id, { focus: false }) + window.dispatchEvent(new CustomEvent(closeEvent, { detail: { tabId } })) // Hide tab A before any deferred post-close work can run. const state = store.getState() const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) state.setActiveTab(tab.id) state.setActiveTabType('terminal', window.__store?.getState().activeWorktreeId ?? null) }, - { tabId, worktreeId } + { tabId, worktreeId, closeEvent: REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } ) await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick') + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'close-then-hide-same-tick') }) test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => { const { worktreeId, tabId } = await setUpSplitTab(orcaPage) await createActiveTerminalTab(orcaPage, worktreeId) - await closeLastPaneOnTab(orcaPage, tabId) + await requestTerminalPaneClose(orcaPage, tabId, 'last') await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS }) await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'close-while-hidden-mounted') }) test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { @@ -308,19 +319,14 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) // Close as soon as the manager exists โ€” panes may still be attaching. - await orcaPage.evaluate((tabId) => { - const manager = window.__paneManagers?.get(tabId) - const target = manager?.getPanes().at(-1) - if (manager && target) { - manager.closePane(target.id) - } - }, tabId) + await requestTerminalPaneClose(orcaPage, tabId, 'last') await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal') + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'close-mid-reveal') }) test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => { const { worktreeId, tabId } = await setUpSplitTab(orcaPage) - await closeLastPaneOnTab(orcaPage, tabId) + await requestTerminalPaneClose(orcaPage, tabId, 'last') await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') await createActiveTerminalTab(orcaPage, worktreeId) await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { @@ -329,6 +335,7 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'post-park-reveal') }) test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => { @@ -341,6 +348,7 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist await activateTerminalTab(orcaPage, tabId) await waitForTabRemounted(orcaPage, tabId) await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId) + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'shell-exit-while-hidden-mounted') }) test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { @@ -357,5 +365,6 @@ test.describe('terminal pane close vs hidden/park lifecycle keeps layout consist // siblings) โ€” the reveal remount owns the per-leaf teardown. This asserts // that ownership actually resolves instead of leaving a ghost pane. await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId) + await expectMainHoldsWindowPanes(orcaPage, worktreeId, tabId, 'shell-exit-while-parked') }) }) diff --git a/tests/e2e/worktree-active-delete-scroll-position.spec.ts b/tests/e2e/worktree-active-delete-scroll-position.spec.ts index 15cf6c979144..e8e166665a08 100644 --- a/tests/e2e/worktree-active-delete-scroll-position.spec.ts +++ b/tests/e2e/worktree-active-delete-scroll-position.spec.ts @@ -1,14 +1,17 @@ -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' const TARGET_INDEX = 24 const SYNTHETIC_COUNT = 40 const VISUAL_PROOF_PAUSE_MS = 1_200 const POST_REMOVAL_SAMPLE_FRAMES = 20 -const MAX_REMOVAL_WAIT_FRAMES = 300 +// A real delete runs git, so wait by time: a frame count shrinks on high-refresh displays. +const MAX_REMOVAL_WAIT_MS = 20_000 -test.use({ minimumSeededWorktreeCount: 1 }) +test.use({ seedTestRepo: false }) type RowRemovalFrame = { animationCount: number @@ -31,109 +34,60 @@ async function pauseForVisualProof(page: Page): Promise { } } -async function seedActiveDeletionRows(page: Page): Promise<{ +async function seedActiveDeletionRows( + page: Page, + testInfo: TestInfo +): Promise<{ belowId: string successorId: string targetId: string }> { - return page.evaluate( - ({ count, targetIndex }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') + const repo = await addRepoWithWorkspaces(page, testInfo, SYNTHETIC_COUNT) + await writeWorkspaceMeta( + page, + repo.ids.map((worktreeId, index) => ({ + worktreeId, + updates: { + displayName: `Active delete row ${String(index).padStart(2, '0')}`, + sortOrder: SYNTHETIC_COUNT - index, + manualOrder: SYNTHETIC_COUNT - index } + })) + ) + const targetId = repo.ids[TARGET_INDEX]! + const belowId = repo.ids[TARGET_INDEX + 1]! + const successorId = repo.ids[0]! + await evaluatePolled( + page, + async ({ repoId, targetId, successorId }) => { + const store = window.__store! const state = store.getState() - const repo = state.repos[0] - const source = repo - ? state.worktreesByRepo[repo.id]?.find((worktree) => worktree.isMainWorktree) - : null - if (!repo || !source || !state.settings) { - throw new Error('Expected a seeded e2e worktree and hydrated settings') - } - - const now = Date.now() - const worktrees = Array.from({ length: count }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - ...source, - id: `${repo.id}::active-delete-${suffix}`, - instanceId: `active-delete-${suffix}`, - path: source.path, - displayName: `Active delete row ${suffix}`, - branch: `active-delete-${suffix}`, - isMainWorktree: false, - isPinned: false, - isUnread: false, - sortOrder: count - index, - manualOrder: count - index, - lastActivityAt: now - index, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null - } + await state.updateSettings({ skipDeleteWorktreeConfirm: true }) + // A setup script keeps the setup guide from opening over the sidebar. + await state.updateRepo(repoId, { + hookSettings: { mode: 'auto', scripts: { setup: 'true', archive: '' } } }) - const target = worktrees[targetIndex] - const below = worktrees[targetIndex + 1] - const successor = worktrees[0] - if (!target || !below || !successor) { - throw new Error('Synthetic worktree fixture is too small') - } - const targetId = target.id - const belowId = below.id - const successorId = successor.id - store.setState({ - activeRepoId: repo.id, + activeRepoId: repoId, activeView: 'terminal', activeWorktreeId: targetId, activeWorkspaceKey: `worktree:${targetId}`, filterRepoIds: [], groupBy: 'none', hideDefaultBranchWorkspace: false, - lastVisitedAtByWorktreeId: { [successorId]: now + 1_000 }, + lastVisitedAtByWorktreeId: { [successorId]: Date.now() + 1_000 }, pendingRevealSidebarRow: null, pendingRevealWorktree: null, - repos: state.repos.map((candidate) => - candidate.id === repo.id - ? { - ...candidate, - hookSettings: { - mode: candidate.hookSettings?.mode ?? 'auto', - ...candidate.hookSettings, - scripts: { - archive: candidate.hookSettings?.scripts.archive ?? '', - setup: 'true' - } - } - } - : candidate - ), - settings: { ...state.settings, skipDeleteWorktreeConfirm: true }, - setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repo.id}`], + setupScriptPromptDismissedRepoIds: [`generation-v1:local\0${repoId}`], showActiveOnly: false, showSleepingWorkspaces: true, sidebarOpen: true, - sortBy: 'manual', - worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: worktrees }, - removeWorktree: async (target) => { - const id = typeof target === 'string' ? target : target.id - store.setState((current) => ({ - activeWorktreeId: current.activeWorktreeId === id ? null : current.activeWorktreeId, - activeWorkspaceKey: current.activeWorktreeId === id ? null : current.activeWorkspaceKey, - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: (current.worktreesByRepo[repo.id] ?? []).filter( - (worktree) => worktree.id !== id - ) - } - })) - return { ok: true } - } + sortBy: 'manual' }) - return { belowId, successorId, targetId } }, - { count: SYNTHETIC_COUNT, targetIndex: TARGET_INDEX } + { repoId: repo.repoId, targetId, successorId } ) + return { belowId, successorId, targetId } } async function prepareScrolledActiveRow(page: Page, targetId: string): Promise { @@ -176,7 +130,7 @@ async function startRowRemovalSampling( belowId: string ): Promise { await page.evaluate( - ({ belowId, maxRemovalWaitFrames, postRemovalSampleFrames, targetId }) => { + ({ belowId, maxRemovalWaitMs, postRemovalSampleFrames, targetId }) => { const sample = async (): Promise => { const readFrame = (): RowRemovalFrame => { const scroller = document.querySelector('[data-worktree-sidebar]') @@ -199,7 +153,8 @@ async function startRowRemovalSampling( } const frames: RowRemovalFrame[] = [readFrame()] let framesAfterRemoval = 0 - for (let index = 0; index < maxRemovalWaitFrames + postRemovalSampleFrames; index += 1) { + const deadline = performance.now() + maxRemovalWaitMs + while (performance.now() < deadline) { await new Promise((resolve) => requestAnimationFrame(() => resolve())) const frame = readFrame() frames.push(frame) @@ -214,7 +169,7 @@ async function startRowRemovalSampling( }, { belowId, - maxRemovalWaitFrames: MAX_REMOVAL_WAIT_FRAMES, + maxRemovalWaitMs: MAX_REMOVAL_WAIT_MS, postRemovalSampleFrames: POST_REMOVAL_SAMPLE_FRAMES, targetId } @@ -233,10 +188,10 @@ async function finishRowRemovalSampling(page: Page): Promise test('deleting the active scrolled worktree preserves position and closes the row gap', async ({ orcaPage -}) => { +}, testInfo) => { await waitForSessionReady(orcaPage) await orcaPage.setViewportSize({ width: 1_200, height: 800 }) - const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage) + const { belowId, successorId, targetId } = await seedActiveDeletionRows(orcaPage, testInfo) await prepareScrolledActiveRow(orcaPage, targetId) const target = orcaPage.locator( `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` @@ -286,47 +241,27 @@ test('deleting the active scrolled worktree preserves position and closes the ro test('reduced motion removes the active row without animating its neighbor', async ({ orcaPage -}) => { +}, testInfo) => { await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) await waitForSessionReady(orcaPage) - const { belowId, targetId } = await seedActiveDeletionRows(orcaPage) + const { belowId, targetId } = await seedActiveDeletionRows(orcaPage, testInfo) await prepareScrolledActiveRow(orcaPage, targetId) - const animationCount = await orcaPage.evaluate( - async ({ belowId, targetId }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - const repoId = repo.id - const worktrees = state.worktreesByRepo[repoId] - if (!worktrees) { - throw new Error('Expected seeded e2e worktrees') + await startRowRemovalSampling(orcaPage, targetId, belowId) + await evaluatePolled( + orcaPage, + async (targetId) => { + const removed = await window + .__store!.getState() + .removeWorktree({ id: targetId, executionHostId: 'local' }) + if (!removed.ok) { + throw new Error(removed.error) } - store.setState({ - activeWorktreeId: null, - activeWorkspaceKey: null, - worktreesByRepo: { - ...state.worktreesByRepo, - [repoId]: worktrees.filter((worktree) => worktree.id !== targetId) - } - }) - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const below = document.querySelector( - `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(belowId)}]` - ) - return ( - below?.closest('[data-worktree-virtual-row]')?.firstElementChild?.getAnimations().length ?? - 0 - ) }, - { belowId, targetId } + targetId ) + const frames = await finishRowRemovalSampling(orcaPage) - expect(animationCount).toBe(0) + expect(frames.some((frame) => !frame.targetExists)).toBe(true) + expect(Math.max(...frames.map((frame) => frame.animationCount))).toBe(0) }) diff --git a/tests/e2e/worktree-card-downward-drag.spec.ts b/tests/e2e/worktree-card-downward-drag.spec.ts index 8ee77548aee0..54d54c8c9c62 100644 --- a/tests/e2e/worktree-card-downward-drag.spec.ts +++ b/tests/e2e/worktree-card-downward-drag.spec.ts @@ -1,9 +1,11 @@ -import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' -const SYNTHETIC_COUNT = 60 +const WORKSPACE_COUNT = 60 + +test.use({ seedTestRepo: false }) type PreviewOffsetSample = { worktreeId: string @@ -11,107 +13,37 @@ type PreviewOffsetSample = { renderedOffset: number } -async function seedVirtualizedManualWorktrees(page: Page): Promise<{ - sourceId: string - nextId: string - idPrefix: string -}> { - const repo = await page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - return { id: repo.id, path: repo.path } - }) - const now = Date.now() - const syntheticWorktrees = Array.from({ length: SYNTHETIC_COUNT }, (_, index) => { - const suffix = String(index).padStart(2, '0') - const worktreePath = path.join(repo.path, '..', `downward-drag-${suffix}`) - return { - id: `${repo.id}::downward-drag-${suffix}`, - instanceId: `downward-drag-${suffix}`, - repoId: repo.id, - path: worktreePath, - displayName: `Downward drag ${suffix}`, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 100_000 - index, - manualOrder: 100_000 - index, - lastActivityAt: now - index, - head: '0000000000000000000000000000000000000000', - branch: `downward-drag-${suffix}`, - isBare: false, - isMainWorktree: false - } - }) - await page.evaluate( - ({ repoId, worktrees }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - const seededWorktrees = (state.worktreesByRepo[repoId] ?? []).map((worktree, index) => ({ - ...worktree, - manualOrder: -1_000 - index - })) - - store.setState({ - groupBy: 'none', - sortBy: 'manual', - showActiveOnly: false, - showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, - filterRepoIds: [], - sidebarOpen: true, - worktreesByRepo: { - ...state.worktreesByRepo, - [repoId]: [...worktrees, ...seededWorktrees] - }, - // Stands in for the real action only to skip its IPC persistence tail: these 60 rows - // are store-only, so persisting them would fail and refetch them away mid-drag. - updateWorktreesMeta: async (batchUpdates) => { - if (batchUpdates.length === 0) { - return - } - // executionHostId is ignored on purpose: every seeded row is host-less, which the - // real action treats as local (worktree-meta-host-match.ts). - const updatesByWorktreeId = new Map( - batchUpdates.map((batchUpdate) => [batchUpdate.worktreeId, batchUpdate.updates]) - ) - store.setState((current) => ({ - sortEpoch: current.sortEpoch + 1, - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ - repoId, - worktrees.map((worktree) => ({ - ...worktree, - ...updatesByWorktreeId.get(worktree.id) - })) - ]) - ) - })) +async function seedVirtualizedManualWorktrees( + page: Page, + testInfo: TestInfo +): Promise<{ sourceId: string; nextId: string; ids: string[] }> { + const repo = await addRepoWithWorkspaces(page, testInfo, WORKSPACE_COUNT) + await writeWorkspaceMeta(page, [ + { worktreeId: repo.mainId, updates: { manualOrder: -1_000, sortOrder: -1_000 } }, + ...repo.ids.map((worktreeId, index) => { + const suffix = String(index).padStart(2, '0') + return { + worktreeId, + updates: { + displayName: `Downward drag ${suffix}`, + sortOrder: 100_000 - index, + manualOrder: 100_000 - index } - }) - }, - { repoId: repo.id, worktrees: syntheticWorktrees } - ) - const sourceId = syntheticWorktrees[0]!.id - return { - sourceId, - nextId: syntheticWorktrees[1]!.id, - idPrefix: sourceId.slice(0, -2) - } + } + }) + ]) + await page.evaluate(() => { + window.__store!.setState({ + groupBy: 'none', + sortBy: 'manual', + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + sidebarOpen: true + }) + }) + return { sourceId: repo.ids[0]!, nextId: repo.ids[1]!, ids: repo.ids } } async function sampleMountedPreviewOffsets( @@ -159,10 +91,12 @@ async function sampleMountedPreviewOffsets( }, sourceId) } -test('dragging a virtualized worktree downward keeps rows stable', async ({ orcaPage }) => { +test('dragging a virtualized worktree downward keeps rows stable', async ({ + orcaPage +}, testInfo) => { await waitForSessionReady(orcaPage) await orcaPage.setViewportSize({ width: 1_000, height: 620 }) - const { sourceId, nextId, idPrefix } = await seedVirtualizedManualWorktrees(orcaPage) + const { sourceId, nextId, ids } = await seedVirtualizedManualWorktrees(orcaPage, testInfo) const scroller = orcaPage.locator('[data-worktree-sidebar]') const source = orcaPage.locator( `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(sourceId)}]` @@ -276,11 +210,11 @@ test('dragging a virtualized worktree downward keeps rows stable', async ({ orca scroller .locator('[role="option"]') .evaluateAll( - (options, prefix) => + (options, ids) => options .map((option) => option.getAttribute('data-worktree-id')) - .find((worktreeId) => worktreeId?.startsWith(prefix)) ?? null, - idPrefix + .find((worktreeId) => worktreeId !== null && ids.includes(worktreeId)) ?? null, + ids ), { message: 'The downward drop did not move the source away from the first slot' } ) diff --git a/tests/e2e/worktree-lineage-indentation.spec.ts b/tests/e2e/worktree-lineage-indentation.spec.ts index f769b512edab..e7f05d8636ec 100644 --- a/tests/e2e/worktree-lineage-indentation.spec.ts +++ b/tests/e2e/worktree-lineage-indentation.spec.ts @@ -1,81 +1,72 @@ import { test, expect } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' import { LINEAGE_IMMEDIATE_PARENT_STEP } from '../../src/renderer/src/components/sidebar/worktree-list/rows/indentation' -test.use({ minimumSeededWorktreeCount: 1 }) +test.use({ seedTestRepo: false }) for (const compactCards of [false, true]) { test(`legacy lineage keeps a local title step (${compactCards ? 'compact' : 'detailed'})`, async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 4) + await writeWorkspaceMeta( + orcaPage, + repo.ids.map((worktreeId, index) => ({ + worktreeId, + updates: { displayName: `Level ${index} workspace`, sortOrder: index } + })) + ) + await linkWorkspaces( + orcaPage, + repo.ids.slice(1).map((childId, index) => ({ childId, parentId: repo.ids[index]! })) + ) for (const showStatus of [false, true]) { for (const group of ['none', 'repo', 'manual', 'deep-manual', 'deep-folder'] as const) { - await orcaPage.evaluate( - ({ compactCards, showStatus, group }) => { - const store = window.__store - if (!store) { - throw new Error('Missing app store') - } + await evaluatePolled( + orcaPage, + async ({ compactCards, showStatus, group, repoId }) => { + const store = window.__store! const state = store.getState() - if (!state.settings) { - throw new Error('Missing app settings') + // Each pass starts from an ungrouped project, so the last pass's groups go. + await window.api.projectGroups.moveProject({ projectId: repoId, groupId: null }) + for (const stale of await window.api.projectGroups.list()) { + await window.api.projectGroups.delete({ groupId: stale.id }) } - const repo = state.repos[0]! - const template = state.worktreesByRepo[repo.id]![0]! const depth = group.startsWith('deep-') ? 3 : group === 'manual' ? 1 : 0 - const projectGroups = Array.from({ length: depth }, (_, index) => ({ - id: `indent-group-${index}`, - name: `Indent group ${index}`, - parentPath: repo.path, - parentGroupId: index > 0 ? `indent-group-${index - 1}` : null, - createdFrom: group === 'deep-folder' ? ('folder-scan' as const) : ('manual' as const), - tabOrder: index, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - })) - const worktrees = Array.from({ length: 4 }, (_, index) => ({ - ...template, - id: `${repo.id}::indent-${index}`, - instanceId: `indent-instance-${index}`, - displayName: `Level ${index} workspace`, - branch: `indent-${index}`, - isMainWorktree: false, - isPinned: false, - isArchived: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - linkedIssue: null, - linkedPR: null, - linkedGitLabMR: null, - linkedLinearIssue: null, - comment: '', - sortOrder: index - })) - const lineage = Object.fromEntries( - worktrees.slice(1).map((child, index) => [ - child.id, - { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: worktrees[index]!.id, - parentWorktreeInstanceId: worktrees[index]!.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: 1 - } - ]) - ) + let parentGroupId: string | null = null + for (let index = 0; index < depth; index++) { + const created = await window.api.projectGroups.create({ + name: `Indent group ${index}`, + parentPath: state.repos.find((entry) => entry.id === repoId)!.path, + parentGroupId, + createdFrom: group === 'deep-folder' ? 'folder-scan' : 'manual' + }) + parentGroupId = created.id + } + if (parentGroupId) { + await window.api.projectGroups.moveProject({ + projectId: repoId, + groupId: parentGroupId + }) + } + await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + await state.fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + // Persist the view first: its echo re-hydrates the window, which must land before the seed. + await window.api.ui.set({ + groupBy: group === 'none' ? 'none' : 'repo', + sortBy: 'manual', + collapsedGroups: [] + }) store.setState({ - repos: [{ ...repo, projectGroupId: projectGroups.at(-1)?.id ?? null }], - worktreesByRepo: { [repo.id]: worktrees }, - worktreeLineageById: lineage, - projectGroups, settings: { - ...state.settings, + ...store.getState().settings!, experimentalNewWorktreeCardStyle: false, compactWorktreeCards: compactCards }, @@ -86,13 +77,14 @@ for (const compactCards of [false, true]) { activeWorktreeId: null, showActiveOnly: false, showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, + // The repo's own checkout is not part of the chain. + hideDefaultBranchWorkspace: true, filterRepoIds: [], workspaceHostScope: 'all', collapsedGroups: new Set() }) }, - { compactCards, showStatus, group } + { compactCards, showStatus, group, repoId: repo.repoId } ) const sidebar = orcaPage.locator('[data-worktree-sidebar]') diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 219bb07c27e2..e4876ce9a458 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -1,4 +1,5 @@ import type { Page } from '@stablyai/playwright-test' +import { linkWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' export type LineageScenario = { parentId: string @@ -9,7 +10,7 @@ export async function seedLineageScenario( page: Page, options: { inlineOnly?: boolean; preserveGrouping?: boolean } = {} ): Promise { - return page.evaluate(({ inlineOnly, preserveGrouping }) => { + const family = await page.evaluate((preserveGrouping) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -40,50 +41,60 @@ export async function seedLineageScenario( if (!parent.instanceId || !child.instanceId) { throw new Error('Worktree lineage E2E needs instance-stamped worktrees') } - const lineage = { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: Date.now() + return { + parentId: parent.id, + childId: child.id, + parentInstanceId: parent.instanceId, + childInstanceId: child.instanceId } - store.setState((current) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ - repoId, - repoWorktrees.map((worktree) => { - if (worktree.id === parent.id) { - return { - ...worktree, - displayName: 'E2E lineage parent', - sortOrder: 0, - ...(inlineOnly - ? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null } - : {}) - } - } - if (worktree.id === child.id) { - return { - ...worktree, - displayName: 'E2E lineage child', - sortOrder: 1, - ...(inlineOnly - ? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage } - : {}) - } - } - return worktree - }) - ]) - ), - worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage } - })) - - store.getState().setActiveWorktree(parent.id) - return { parentId: parent.id, childId: child.id } - }, options) + }, options.preserveGrouping ?? false) + const { parentId, childId } = family + await writeWorkspaceMeta(page, [ + { worktreeId: parentId, updates: { displayName: 'E2E lineage parent', sortOrder: 0 } }, + { worktreeId: childId, updates: { displayName: 'E2E lineage child', sortOrder: 1 } } + ]) + if (!options.inlineOnly) { + await linkWorkspaces(page, [{ childId, parentId }]) + } + if (options.inlineOnly) { + // Window-only on purpose: the records' inline link with no side map, which main cannot produce. + await page.evaluate(({ parentId, childId, parentInstanceId, childInstanceId }) => { + const lineage = { + worktreeId: childId, + worktreeInstanceId: childInstanceId, + parentWorktreeId: parentId, + parentWorktreeInstanceId: parentInstanceId, + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: Date.now() + } + window.__store!.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ + repoId, + repoWorktrees.map((worktree) => + worktree.id === parentId + ? { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + } + : worktree.id === childId + ? { ...worktree, parentWorktreeId: parentId, childWorktreeIds: [], lineage } + : worktree + ) + ]) + ), + worktreeLineageById: {} + })) + }, family) + } + await page.evaluate( + (parentId) => window.__store!.getState().setActiveWorktree(parentId), + parentId + ) + return { parentId, childId } } export async function seedWorkspaceAgentStatus( diff --git a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts index 0e7b078f1a13..8c743762c542 100644 --- a/tests/e2e/worktree-lineage-toggle-scroll.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-scroll.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady, waitForActiveWorktree } from './helpers/store' +import { addRepoWithWorkspaces, writeWorkspaceMeta } from './helpers/repo-with-workspaces' import { seedLineageScenario } from './worktree-lineage-state' import { waitForLineageScrollFixtureReady } from './worktree-lineage-scroll-readiness' @@ -19,45 +20,22 @@ test('keyboard and chip preserve the scrolled parent, and unfolding opens the si await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) await orcaPage.setViewportSize({ width: 1200, height: 800 }) const family = await seedLineageScenario(orcaPage) - await orcaPage.evaluate(async ({ parentId, childId }) => { - const store = window.__store - if (!store) { - throw new Error('Missing store') - } - const state = store.getState() - const parent = Object.values(state.worktreesByRepo) - .flat() - .find((row) => row.id === parentId) - if (!parent) { - throw new Error('Missing parent') - } - const surrounding = Array.from({ length: 40 }, (_, index) => ({ - ...parent, - id: `scroll-card-${index}`, - instanceId: `scroll-instance-${index}`, - displayName: `Surrounding workspace ${index}`, - isMainWorktree: false, - isPinned: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - sortOrder: 40 - index - })) - store.setState({ - sortBy: 'manual', - worktreesByRepo: { - ...state.worktreesByRepo, - [parent.repoId]: [ - ...state.worktreesByRepo[parent.repoId].map((row) => ({ - ...row, - sortOrder: row.id === parentId ? 16.5 : row.id === childId ? 16.4 : row.sortOrder - })), - ...surrounding - ] - } - }) - await store.getState().setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) - }, family) + const surrounding = await addRepoWithWorkspaces(orcaPage, testInfo, 40) + const order = (worktreeId: string, rank: number, displayName?: string) => ({ + worktreeId, + updates: { sortOrder: rank, manualOrder: rank, ...(displayName ? { displayName } : {}) } + }) + await writeWorkspaceMeta(orcaPage, [ + order(family.parentId, 16.5), + order(family.childId, 16.4), + order(surrounding.mainId, 0), + ...surrounding.ids.map((id, index) => order(id, 40 - index, `Surrounding workspace ${index}`)) + ]) + await orcaPage.evaluate(async () => { + const state = window.__store!.getState() + state.setSortBy('manual') + await state.setKeybindingOverride('sidebar.childWorkspaces.toggle', ['Mod+Alt+H']) + }) const sidebar = orcaPage.locator('[data-worktree-sidebar]') const parentRow = sidebar diff --git a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts index 6187e43e2fe3..622255020b04 100644 --- a/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts +++ b/tests/e2e/worktree-lineage-toggle-shortcut.spec.ts @@ -5,6 +5,11 @@ import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { readPaneIdentitySnapshot } from './helpers/terminal' import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { seedLineageScenario, seedWorkspaceLiveTerminal } from './worktree-lineage-state' test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) @@ -192,55 +197,19 @@ test.describe('Toggle Child Workspaces shortcut', () => { await orcaPage.keyboard.press('Escape') }) - test('a hovered parent takes precedence over another active family', async ({ orcaPage }) => { - const { parentId, childId } = await seedLineageScenario(orcaPage) + test('a hovered parent takes precedence over another active family', async ({ + orcaPage + }, testInfo) => { + const { childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - const other = await orcaPage.evaluate( - ({ parentId, childId }) => { - const store = window.__store - if (!store) { - throw new Error('Missing store') - } - const state = store.getState() - const worktrees = Object.values(state.worktreesByRepo).flat() - const parent = worktrees.find((worktree) => worktree.id === parentId) - const child = worktrees.find((worktree) => worktree.id === childId) - const edge = state.worktreeLineageById[childId] - if (!parent || !child || !edge) { - throw new Error('Missing seeded family') - } - const otherParent = { - ...parent, - id: `${parentId}-other`, - instanceId: 'other-parent', - displayName: 'Another parent' - } - const otherChild = { - ...child, - id: `${childId}-other`, - instanceId: 'other-child', - displayName: 'Another child' - } - store.setState({ - worktreesByRepo: { - ...state.worktreesByRepo, - [parent.repoId]: [...state.worktreesByRepo[parent.repoId], otherParent, otherChild] - }, - worktreeLineageById: { - ...state.worktreeLineageById, - [otherChild.id]: { - ...edge, - worktreeId: otherChild.id, - worktreeInstanceId: otherChild.instanceId, - parentWorktreeId: otherParent.id, - parentWorktreeInstanceId: otherParent.instanceId - } - } - }) - return { parentId: otherParent.id, childId: otherChild.id } - }, - { parentId, childId } - ) + const otherRepo = await addRepoWithWorkspaces(orcaPage, testInfo, 2) + const other = { parentId: otherRepo.ids[0]!, childId: otherRepo.ids[1]! } + await writeWorkspaceMeta(orcaPage, [ + { worktreeId: other.parentId, updates: { displayName: 'Another parent' } }, + { worktreeId: other.childId, updates: { displayName: 'Another child' } } + ]) + await linkWorkspaces(orcaPage, [other]) + await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeVisible() await sidebarWorktreeRow(orcaPage, other.parentId).hover() await orcaPage.keyboard.press(CHORD) await expect(sidebarWorktreeRow(orcaPage, other.childId)).toBeHidden() @@ -268,18 +237,10 @@ test.describe('Toggle Child Workspaces shortcut', () => { }) => { const { parentId, childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - await orcaPage.evaluate((childId) => { - window.__store?.setState((state) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => - row.id === childId ? { ...row, instanceId: 'recreated-child' } : row - ) - ]) - ) - })) - }, childId) + // Stands in for recreating the child at the same path: its new instance no longer matches the link. + await writeWorkspaceMeta(orcaPage, [ + { worktreeId: childId, updates: { instanceId: 'recreated-child' } } + ]) const parentRow = sidebarWorktreeRow(orcaPage, parentId) await expect(parentRow.getByRole('button', { name: /child workspace/ })).toHaveCount(0) await parentRow.click() @@ -375,21 +336,14 @@ test.describe('Toggle Child Workspaces shortcut', () => { }) => { const { parentId, childId } = await seedLineageScenario(orcaPage) await setToggleBinding(orcaPage, ['Mod+Alt+H']) - await orcaPage.evaluate(async (parentId) => { + await writeWorkspaceMeta(orcaPage, [{ worktreeId: parentId, updates: { isPinned: true } }]) + await orcaPage.evaluate(async () => { await window.__store?.getState().updateSettings({ showPinnedWorktreesInGroups: true }) - window.__store?.setState((state) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => (row.id === parentId ? { ...row, isPinned: true } : row)) - ]) - ) - })) const sidebar = document.querySelector('[data-worktree-sidebar]') sidebar?.addEventListener('orca-record-virtualized-scroll-anchor', () => { sidebar.dataset.anchorRecords = String(Number(sidebar.dataset.anchorRecords ?? 0) + 1) }) - }, parentId) + }) const chips = orcaPage.getByRole('button', { name: 'Hide 1 child workspace', exact: true }) await expect(chips).toHaveCount(2) const natural = orcaPage diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 0f9ddb334966..55727c136d4f 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -2,6 +2,8 @@ import type { Locator, Page } from '@stablyai/playwright-test' import { mkdirSync } from 'node:fs' import { resolve } from 'node:path' import { test, expect } from './helpers/orca-app' +import { addRepoWithWorkspaces, linkWorkspaces } from './helpers/repo-with-workspaces' +import { SIDEBAR_INPUTS_BUILD } from './helpers/sidebar-inputs-build' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { markWorkspaceTerminalSlept, @@ -110,6 +112,10 @@ test.describe('Worktree Lineage', () => { test('renders legacy-only inline lineage when side-map hydration is absent', async ({ orcaPage }) => { + test.skip( + SIDEBAR_INPUTS_BUILD, + "the window's legacy inline link is unused when rows come from main" + ) const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true }) const parentRow = worktreeOption(orcaPage, parentId) const childRow = worktreeOption(orcaPage, childId) @@ -125,6 +131,10 @@ test.describe('Worktree Lineage', () => { return parentBox && childBox ? childBox.y > parentBox.y : false }) .toBe(true) + // The link must come from the records alone, not a side map hydrated after the seed. + expect( + await orcaPage.evaluate((id) => window.__store!.getState().worktreeLineageById[id], childId) + ).toBeUndefined() await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png') }) @@ -211,24 +221,12 @@ test.describe('Worktree Lineage', () => { test('sleeps a workspace and every descendant from the parent context menu', async ({ orcaPage - }) => { - const { parentId, childId } = await seedLineageScenario(orcaPage) - await orcaPage.evaluate((parentId) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - store.setState((current) => ({ - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ - repoId, - worktrees.map((worktree) => - worktree.id === parentId ? { ...worktree, isMainWorktree: false } : worktree - ) - ]) - ) - })) - }, parentId) + }, testInfo) => { + // A repo's own checkout offers no delete, so the parent is one of its worktrees. + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 2) + const [parentId, childId] = [repo.ids[0]!, repo.ids[1]!] + await linkWorkspaces(orcaPage, [{ childId, parentId }]) + await orcaPage.evaluate(() => window.__store!.getState().setShowSleepingWorkspaces(true)) const parentTabId = await seedWorkspaceLiveTerminal(orcaPage, parentId) const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId) diff --git a/tests/e2e/worktree-parent-reorder.spec.ts b/tests/e2e/worktree-parent-reorder.spec.ts index 21f96f0c0d33..1eddc06de22d 100644 --- a/tests/e2e/worktree-parent-reorder.spec.ts +++ b/tests/e2e/worktree-parent-reorder.spec.ts @@ -1,82 +1,64 @@ import { test, expect } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { + addRepoWithWorkspaces, + linkWorkspaces, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { waitForSessionReady } from './helpers/store' +test.use({ seedTestRepo: false }) + for (const newCardStyle of [false, true]) { test(`reorders a collapsed parent with 53 children (${newCardStyle ? 'new' : 'legacy'} cards)`, async ({ orcaPage }, testInfo) => { await waitForSessionReady(orcaPage) - const ids = await orcaPage.evaluate(async (newCardStyle) => { - const store = window.__store - if (!store) { - throw new Error('Missing app store') - } - const state = store.getState() - const repo = state.repos[0]! - const template = state.worktreesByRepo[repo.id]![0]! - const makeRow = (name: string, rank: number) => ({ - ...template, - id: `${repo.id}::${name}`, - instanceId: name, - displayName: name, - branch: `refs/heads/${name}`, - isMainWorktree: false, - isPinned: false, - isArchived: false, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - manualOrder: rank, - sortOrder: rank - }) - const first = makeRow('First workspace', 100_000) - const parent = makeRow('Parent with 53 children', 90_000) - const last = makeRow('Last workspace', 10_000) - const children = Array.from({ length: 53 }, (_, index) => { - const child = makeRow(`Child ${index}`, 80_000 - index * 100) - const lineage = { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual' as const, - capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, - createdAt: Date.now() - } - return { ...child, parentWorktreeId: parent.id, lineage } - }) - const collapsedGroups = [ - `lineage:${parent.hostId ? `${parent.hostId}|${parent.id}` : parent.id}` - ] - await window.api.ui.set({ groupBy: 'none', collapsedGroups }) - store.setState({ - groupBy: 'none', - sortBy: 'manual', - sidebarOpen: true, - settings: { ...state.settings, experimentalNewWorktreeCardStyle: newCardStyle }, - showActiveOnly: false, - showSleepingWorkspaces: true, - hideDefaultBranchWorkspace: false, - filterRepoIds: [], - collapsedGroups: new Set(collapsedGroups), - worktreesByRepo: { [repo.id]: [first, parent, ...children, last] }, - worktreeLineageById: Object.fromEntries(children.map((child) => [child.id, child.lineage])), - // Synthetic rows exercise the real drag path without persisting nonexistent checkouts. - updateWorktreesMeta: async (updates) => { - const byId = new Map(updates.map((update) => [update.worktreeId, update.updates])) - store.setState((current) => ({ - sortEpoch: current.sortEpoch + 1, - worktreesByRepo: Object.fromEntries( - Object.entries(current.worktreesByRepo).map(([repoId, rows]) => [ - repoId, - rows.map((row) => ({ ...row, ...byId.get(row.id) })) - ]) - ) - })) - } - }) - return { first: first.id, parent: parent.id, last: last.id } - }, newCardStyle) + // First, parent, 53 children, last; the repo's own checkout sorts below them all. + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 56) + const [firstId, parentId, ...rest] = repo.ids + const childIds = rest.slice(0, 53) + const lastId = rest[53]! + const rank = (id: string, name: string, manualOrder: number) => ({ + worktreeId: id, + updates: { displayName: name, manualOrder, sortOrder: manualOrder } + }) + await writeWorkspaceMeta(orcaPage, [ + rank(repo.mainId, 'Main workspace', 1), + rank(firstId!, 'First workspace', 100_000), + rank(parentId!, 'Parent with 53 children', 90_000), + ...childIds.map((id, index) => rank(id, `Child ${index}`, 80_000 - index * 100)), + rank(lastId, 'Last workspace', 10_000) + ]) + await linkWorkspaces( + orcaPage, + childIds.map((childId) => ({ childId, parentId: parentId! })) + ) + await evaluatePolled( + orcaPage, + async ({ newCardStyle, parentId }) => { + const store = window.__store! + const state = store.getState() + const parent = state.allWorktrees().find((worktree) => worktree.id === parentId)! + const collapsedGroups = [ + `lineage:${parent.hostId ? `${parent.hostId}|${parent.id}` : parent.id}` + ] + await window.api.ui.set({ groupBy: 'none', collapsedGroups }) + await state.updateSettings({ experimentalNewWorktreeCardStyle: newCardStyle }) + store.setState({ + groupBy: 'none', + sortBy: 'manual', + sidebarOpen: true, + showActiveOnly: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + filterRepoIds: [], + collapsedGroups: new Set(collapsedGroups) + }) + }, + { newCardStyle, parentId: parentId! } + ) + const ids = { first: firstId!, parent: parentId!, last: lastId } const sidebar = orcaPage.locator('[data-worktree-sidebar]') const parent = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.parent)}]`) const last = sidebar.locator(`[data-worktree-id=${JSON.stringify(ids.last)}]`) diff --git a/tests/e2e/worktree-reveal-host-sections.spec.ts b/tests/e2e/worktree-reveal-host-sections.spec.ts index f3396af74261..f211143dce8b 100644 --- a/tests/e2e/worktree-reveal-host-sections.spec.ts +++ b/tests/e2e/worktree-reveal-host-sections.spec.ts @@ -1,9 +1,15 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { - waitForActiveWorktree, - waitForSessionReady, - waitForStartupWorktreeRefresh -} from './helpers/store' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' +import { attachRepoAndOpenTerminal } from './helpers/orca-restart' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { launchWithUnreachableSshWorkspaces } from './helpers/seeded-ssh-workspaces' +import { waitForSessionReady, waitForStartupWorktreeRefresh } from './helpers/store' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../src/shared/execution-host' const sections = [ { groupBy: 'none', key: 'pinned', label: 'Pinned', pinned: true }, @@ -17,163 +23,245 @@ const sections = [ { groupBy: 'pr-status', key: 'pr:in-progress', label: 'In progress', pinned: false } ] as const -for (const section of sections) { - test(`reveals ${section.key} workspaces without expanding another host's section`, async ({ - orcaPage - }, testInfo) => { - await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await waitForStartupWorktreeRefresh(orcaPage) - for (const hostId of ['ssh:reveal-builder', 'runtime:reveal-builder'] as const) { - for (const collapsedHost of [false, true]) { - const scenario = `${hostId.split(':')[0]}-collapsed-${collapsedHost}` - await test.step(scenario, async () => { - const ids = await orcaPage.evaluate( - async ({ hostId, collapsedHost, section }) => { - const store = window.__store! - const state = store.getState() - const sourceRepo = state.repos[0] - const sourceWorktree = state.worktreesByRepo[sourceRepo.id][0] - await state.updateWorktreeMeta(sourceWorktree.id, { - isPinned: section.pinned, - displayName: 'Local workspace' - }) - const local = { - ...sourceWorktree, - hostId: 'local' as const, - isPinned: section.pinned, - displayName: 'Local workspace' - } - const remoteRepo = { - ...sourceRepo, - id: 'reveal-remote-repo', - executionHostId: hostId, - connectionId: hostId.startsWith('ssh:') ? 'reveal-builder' : null, - displayName: 'Remote project' - } - const remote = { - ...sourceWorktree, - id: 'reveal-remote-workspace', - repoId: remoteRepo.id, - hostId, - isPinned: section.pinned, - isMainWorktree: false, - displayName: 'Remote workspace' - } - state.setActiveView('terminal') - state.setSidebarOpen(true) - await window.api.ui.set({ groupBy: section.groupBy }) - state.setShowSleepingWorkspaces(true) - state.setHideDefaultBranchWorkspace(false) - state.setFilterRepoIds([]) - const collapsedGroups = [ - section.key, - `${section.key}:host:${hostId}`, - ...(collapsedHost ? [`host:${hostId}`] : []) - ] - await window.api.ui.set({ collapsedGroups }) - store.setState({ - groupBy: section.groupBy, - repos: [sourceRepo, remoteRepo], - worktreesByRepo: { [sourceRepo.id]: [local], [remoteRepo.id]: [remote] }, - sshTargetLabels: new Map([['reveal-builder', 'Remote builder']]), - sshConnectionStates: new Map([ - [ - 'reveal-builder', - { - targetId: 'reveal-builder', - status: 'connected', - error: null, - reconnectAttempt: 0 - } - ] - ]), - visibleWorkspaceHostIds: ['local', hostId], - workspaceHostScope: 'all', - activeRepoId: remoteRepo.id, - activeWorktreeId: remote.id, - activeWorkspaceKey: `worktree:${remote.id}`, - activeWorkspaceExecutionHostId: hostId, - pendingRevealWorktree: null, - collapsedGroups: new Set(collapsedGroups) - }) - return { local: local.id, remote: remote.id } - }, - { hostId, collapsedHost, section } - ) - const headers = orcaPage.getByRole('button', { name: new RegExp(`^${section.label}`) }) - const localRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.local)}]` - ) - const remoteRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.remote)}]` - ) - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') - } - await expect(localRow).toHaveCount(0) - await expect(remoteRow).toHaveCount(0) - await orcaPage.screenshot({ - path: testInfo.outputPath(`${scenario}-before-reveal.png`), - clip: { x: 0, y: 170, width: 280, height: 500 } - }) - await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() - await expect(remoteRow).toBeVisible() - await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') - await orcaPage.screenshot({ - path: testInfo.outputPath(`${scenario}-after-reveal.png`), - clip: { x: 0, y: 170, width: 280, height: 500 } - }) - await expect(headers).toHaveCount(2) - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') - } - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.last()).toHaveAttribute('aria-expanded', 'true') - } - await expect(localRow).toHaveCount(0) +/** Pins through main and waits for its answer. */ +async function pinWorkspace( + page: Page, + worktreeId: string, + isPinned: boolean, + executionHostId: ExecutionHostId +): Promise { + await evaluatePolled( + page, + async (args) => { + const written = await window.__store!.getState().updateWorktreeMeta( + args.worktreeId, + { isPinned: args.isPinned }, + { + executionHostId: args.executionHostId + } + ) + if (!written.ok) { + throw new Error(`Pinning failed: ${written.error}`) + } + }, + { worktreeId, isPinned, executionHostId } + ) +} + +/** A desktop whose main holds a local workspace and one on another host, as a user's would. */ +type RevealTopology = { + page: Page + local: string + remote: string + remoteHostId: ExecutionHostId + setRemotePinned: (pinned: boolean) => Promise +} - await headers.last().click() - await expect(remoteRow).toHaveCount(0) - await orcaPage.evaluate((localId) => { +async function expectRevealsWithinHost( + { page, local, remote, remoteHostId, setRemotePinned }: RevealTopology, + testInfo: TestInfo +): Promise { + for (const section of sections) { + for (const collapsedHost of [false, true]) { + const scenario = `${section.key}-collapsed-${collapsedHost}` + await test.step(scenario, async () => { + await setRemotePinned(section.pinned) + await pinWorkspace(page, local, section.pinned, 'local') + // The window must show both pins before the reveal, or the row opens in the other section. + await expect + .poll(() => + page.evaluate( + (ids) => + Object.values(window.__store!.getState().worktreesByRepo) + .flat() + .filter((worktree) => ids.includes(worktree.id)) + .map((worktree) => worktree.isPinned), + [local, remote] + ) + ) + .toEqual([section.pinned, section.pinned]) + const collapsedGroups = [ + section.key, + `${section.key}:host:${remoteHostId}`, + ...(collapsedHost ? [`host:${remoteHostId}`] : []) + ] + await evaluatePolled( + page, + async ({ remote, remoteHostId, collapsedGroups, section }) => { const store = window.__store! - const local = Object.values(store.getState().worktreesByRepo) + const state = store.getState() + const remoteRepoId = Object.values(state.worktreesByRepo) .flat() - .find((worktree) => worktree.id === localId)! + .find((worktree) => worktree.id === remote)!.repoId + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + await window.api.ui.set({ groupBy: section.groupBy, collapsedGroups }) store.setState({ - activeRepoId: local.repoId, - activeWorktreeId: localId, - activeWorkspaceKey: `worktree:${localId}`, - activeWorkspaceExecutionHostId: 'local', - pendingRevealWorktree: null - }) - }, ids.local) - await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() - await expect(localRow).toBeVisible() - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'true') - await expect(headers.last()).toHaveAttribute('aria-expanded', 'false') - } - await expect(remoteRow).toHaveCount(0) - // Finish the reveal and its persistence before resetting the next scenario. - await expect - .poll(() => - orcaPage.evaluate(async () => ({ - pendingRevealWorktree: window.__store!.getState().pendingRevealWorktree, - collapsedGroups: [...window.__store!.getState().collapsedGroups].sort(), - persistedCollapsedGroups: (await window.api.ui.get()).collapsedGroups?.sort() - })) - ) - .toEqual({ + groupBy: section.groupBy, + visibleWorkspaceHostIds: ['local', remoteHostId], + workspaceHostScope: 'all', + activeRepoId: remoteRepoId, + activeWorktreeId: remote, + activeWorkspaceKey: `worktree:${remote}`, + activeWorkspaceExecutionHostId: remoteHostId, pendingRevealWorktree: null, - collapsedGroups: [`${section.key}:host:${hostId}`], - persistedCollapsedGroups: [`${section.key}:host:${hostId}`] + collapsedGroups: new Set(collapsedGroups) }) - await expect(localRow).toBeVisible() - await expect(remoteRow).toHaveCount(0) + }, + { remote, remoteHostId, collapsedGroups, section } + ) + const headers = page.getByRole('button', { name: new RegExp(`^${section.label}`) }) + const localRow = page.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(local)}]` + ) + const remoteRow = page.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(remote)}]` + ) + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') + } + await expect(localRow).toHaveCount(0) + await expect(remoteRow).toHaveCount(0) + await page.screenshot({ + path: testInfo.outputPath(`${scenario}-before-reveal.png`), + clip: { x: 0, y: 170, width: 280, height: 500 } }) - } + await page.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(remoteRow).toBeVisible() + await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + await page.screenshot({ + path: testInfo.outputPath(`${scenario}-after-reveal.png`), + clip: { x: 0, y: 170, width: 280, height: 500 } + }) + await expect(headers).toHaveCount(2) + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') + await expect(headers.last()).toHaveAttribute('aria-expanded', 'true') + } + await expect(localRow).toHaveCount(0) + + await headers.last().click() + await expect(remoteRow).toHaveCount(0) + await page.evaluate((localId) => { + const store = window.__store! + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((entry) => entry.id === localId)! + store.setState({ + activeRepoId: worktree.repoId, + activeWorktreeId: localId, + activeWorkspaceKey: `worktree:${localId}`, + activeWorkspaceExecutionHostId: 'local', + pendingRevealWorktree: null + }) + }, local) + await page.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(localRow).toBeVisible() + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'true') + await expect(headers.last()).toHaveAttribute('aria-expanded', 'false') + } + await expect(remoteRow).toHaveCount(0) + // Finish the reveal and its persistence before resetting the next scenario. + await expect + .poll(() => + page.evaluate(async () => ({ + pendingRevealWorktree: window.__store!.getState().pendingRevealWorktree, + collapsedGroups: [...window.__store!.getState().collapsedGroups].sort(), + persistedCollapsedGroups: (await window.api.ui.get()).collapsedGroups?.sort() + })) + ) + .toEqual({ + pendingRevealWorktree: null, + collapsedGroups: [`${section.key}:host:${remoteHostId}`], + persistedCollapsedGroups: [`${section.key}:host:${remoteHostId}`] + }) + await expect(localRow).toBeVisible() + await expect(remoteRow).toHaveCount(0) + }) } - }) + } } + +test("reveals an unreachable SSH host's workspaces without expanding another host's section", async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(4 * 60_000) + const launched = await launchWithUnreachableSshWorkspaces(testInfo, testRepoPath, { + label: 'Remote builder', + workspaces: [{ name: 'feature', displayName: 'Remote workspace' }] + }) + try { + const { page, ssh, localWorktreeId } = launched + await page.emulateMedia({ reducedMotion: 'reduce' }) + const remote = ssh.worktreeIds.feature! + await expectRevealsWithinHost( + { + page, + local: localWorktreeId, + remote, + remoteHostId: ssh.hostId, + setRemotePinned: (isPinned) => pinWorkspace(page, remote, isPinned, ssh.hostId) + }, + testInfo + ) + } finally { + await launched.dispose() + } +}) + +test("reveals a paired server's workspaces without expanding another host's section", async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(4 * 60_000) + const serverRepo = testInfo.outputPath('server-repo') + mkdirSync(serverRepo, { recursive: true }) + writeFileSync(path.join(serverRepo, 'README.md'), 'server repo\n') + const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' }) + git('init', '-b', 'main') + git('add', 'README.md') + git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init') + + const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'runtime' }) + try { + const desktop = await launchPairedElectronClient(host.offer, testInfo, 'reveal-host-sections') + try { + const { page } = desktop + await page.emulateMedia({ reducedMotion: 'reduce' }) + await waitForSessionReady(page) + const local = await attachRepoAndOpenTerminal(page, testRepoPath) + await waitForStartupWorktreeRefresh(page) + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: serverRepo, + kind: 'git' + }) + const created = await host.client.call<{ worktree: { id: string } }>('worktree.create', { + repo: `id:${added.result.repo.id}`, + name: 'feature', + displayName: 'Remote workspace', + setupDecision: 'skip' + }) + const remote = created.result.worktree.id + const remoteHostId = toRuntimeExecutionHostId(desktop.environmentId) + await expectRevealsWithinHost( + { + page, + local, + remote, + remoteHostId, + setRemotePinned: async (isPinned) => { + await host.client.call('worktree.set', { worktree: `id:${remote}`, isPinned }) + } + }, + testInfo + ) + } finally { + await desktop.dispose() + } + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 07cc6340897f..4d7abd0bb1c1 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,7 +1,14 @@ -import { mkdirSync } from 'node:fs' +import { mkdirSync, realpathSync } from 'node:fs' +import path from 'node:path' import { runProcess } from '@orca/process-host' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { evaluatePolled } from './helpers/page-evaluate-polled' +import { + addRepoWithWorkspaces, + bulkWriteTimeout, + writeWorkspaceMeta +} from './helpers/repo-with-workspaces' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' async function prepareSidebarForScrollTest(page: Page): Promise { @@ -167,64 +174,34 @@ test.describe('Reveal active workspace button', () => { test('reveals the current workspace when it starts outside the virtualized window', async ({ orcaPage - }) => { + }, testInfo) => { await prepareSidebarForScrollTest(orcaPage) - const targetId = await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - const repo = state.repos[0] - if (!repo) { - throw new Error('Expected a seeded e2e repo') - } - - const now = Date.now() - const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] - const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - id: `${repo.id}::/virtual-reveal-${suffix}`, - instanceId: `virtual-reveal-${suffix}`, - repoId: repo.id, - path: `${repo.path}/../virtual-reveal-${suffix}`, - displayName: `Virtual reveal ${suffix}`, - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, + const repo = await addRepoWithWorkspaces(orcaPage, testInfo, 60) + await writeWorkspaceMeta( + orcaPage, + repo.ids.map((worktreeId, index) => ({ + worktreeId, + updates: { + displayName: `Virtual reveal ${String(index).padStart(2, '0')}`, sortOrder: 10_000 - index, - lastActivityAt: now - index - 100, - head: '0000000000000000000000000000000000000000', - branch: `virtual-reveal-${suffix}`, - isBare: false, - isMainWorktree: false + manualOrder: 10_000 - index } - }) - const target = syntheticWorktrees.at(-1) - if (!target) { - throw new Error('Expected a synthetic target worktree') - } - - store.setState({ - activeRepoId: repo.id, - activeWorktreeId: target.id, - activeWorkspaceKey: `worktree:${target.id}`, - pendingRevealWorktree: null, - sortBy: 'manual', - worktreesByRepo: { - ...state.worktreesByRepo, - [repo.id]: [...seededWorktrees, ...syntheticWorktrees] - } - }) - return target.id - }) + })) + ) + const targetId = repo.ids.at(-1)! + await orcaPage.evaluate( + ({ repoId, targetId }) => { + window.__store!.setState({ + activeRepoId: repoId, + activeWorktreeId: targetId, + activeWorkspaceKey: `worktree:${targetId}`, + pendingRevealWorktree: null, + sortBy: 'manual' + }) + }, + { repoId: repo.repoId, targetId } + ) const scroller = orcaPage.locator('[data-worktree-sidebar]') await expect @@ -278,62 +255,55 @@ test.describe('Reveal active workspace button', () => { test('uses the active workspace key when the legacy active worktree id is not set', async ({ orcaPage - }) => { + }, testInfo) => { await prepareSidebarForScrollTest(orcaPage) - const folderWorktreeId = await orcaPage.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const now = Date.now() - const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { - const suffix = String(index).padStart(2, '0') - return { - id: `reveal-folder-workspace-${suffix}`, - projectGroupId: 'reveal-folder-group', - name: `Reveal folder workspace ${suffix}`, - folderPath: `/tmp/reveal-folder-workspace-${suffix}`, - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1_000 - index, - lastActivityAt: now - index, - createdAt: now, - updatedAt: now - } - }) - const targetFolder = folderWorkspaces.at(-1) - if (!targetFolder) { - throw new Error('Expected a target folder workspace') - } - const folderWorktreeId = `folder:${targetFolder.id}` - store.setState({ - activeRepoId: null, - activeWorktreeId: null, - activeWorkspaceKey: folderWorktreeId, - projectGroups: [ - { - id: 'reveal-folder-group', - name: 'Reveal folder group', - parentPath: '/tmp/reveal-folder-group', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 1, - isCollapsed: false, - color: null, - createdAt: now, - updatedAt: now - } - ], - folderWorkspaces - }) - store.getState().setGroupBy('repo') - return folderWorktreeId + // The oldest of 36 folder workspaces sorts last under Recent. + const groupPath = testInfo.outputPath('reveal-folder-group') + const folderPaths = Array.from({ length: 36 }, (_, index) => { + const folderPath = path.join(groupPath, `folder-${String(index).padStart(2, '0')}`) + mkdirSync(folderPath, { recursive: true }) + return realpathSync(folderPath) }) + const folderWorktreeId = await evaluatePolled( + orcaPage, + async ({ parentPath, folderPaths }) => { + const store = window.__store! + const group = await window.api.projectGroups.create({ + name: 'Reveal folder group', + parentPath, + createdFrom: 'manual' + }) + await store + .getState() + .fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true }) + const ids: string[] = [] + for (const [index, folderPath] of folderPaths.entries()) { + const folder = await store.getState().createFolderWorkspace( + { + projectGroupId: group.id, + name: `Reveal folder workspace ${String(index).padStart(2, '0')}`, + folderPath + }, + { runtimeEnvironmentId: null } + ) + if (!folder) { + throw new Error(`Folder workspace ${index} was not created`) + } + ids.push(folder.id) + } + const targetKey = `folder:${ids[0]}` as const + store.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: targetKey + }) + store.getState().setGroupBy('repo') + return targetKey + }, + { parentPath: realpathSync(groupPath), folderPaths }, + { timeout: bulkWriteTimeout(folderPaths.length) } + ) const scroller = orcaPage.locator('[data-worktree-sidebar]') await scroller.evaluate((element) => {