diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f94e38ad9f3..c15f386cf2a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,6 +10,8 @@ body: value: | Use this form for Orca bugs. Include enough detail for someone else to reproduce the issue. + Please write your issue in English. + - type: dropdown id: os attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 800201387db..632c024d13c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -10,6 +10,8 @@ body: value: | Use this form for new features or workflow improvements. + Please write your issue in English. + - type: textarea id: problem attributes: diff --git a/.github/ISSUE_TEMPLATE/other.yml b/.github/ISSUE_TEMPLATE/other.yml index 3da84207303..e5f49b196e2 100644 --- a/.github/ISSUE_TEMPLATE/other.yml +++ b/.github/ISSUE_TEMPLATE/other.yml @@ -2,6 +2,11 @@ name: Other description: Report another Orca issue type title: '[Other]: ' body: + - type: markdown + attributes: + value: | + Please write your issue in English. + - type: textarea id: details attributes: diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml new file mode 100644 index 00000000000..325ab73a847 --- /dev/null +++ b/.github/workflows/adhoc-mac-build.yml @@ -0,0 +1,343 @@ +name: Adhoc macOS Dev Build + +# Why: lets anyone cut a signed macOS build of an unlanded branch so the team can +# actually run an experimental feature for a few days, instead of reasoning about +# it from a diff. Hourly covers main; this covers everything that is not main yet. +# +# Deliberately narrow scope, same trade as hourly: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. PR CI and release-cut remain the gates. +# - Signed AND notarized, exactly like a release. macOS anchors a notarized +# app's TCC grants on identifier + team rather than on its cdhash, so those +# grants survive an update; an unnotarized build reads as a new client and +# silently loses file access under Documents/Desktop/Downloads. +# +# Artifacts publish to stablyai/orca-adhoc — separate from both orca and +# orca-hourly. Separate from orca because the main repo's releases atom feed +# exposes only its 10 newest entries. Separate from orca-hourly because a build +# from someone's branch must never be picked up by a developer who only meant to +# ride main; the two are different levels of "unvetted". +# +# From the Actions tab: pick this workflow, "Run workflow", leave "Use workflow +# from" on main, and type your branch in the first field. Or from the CLI: +# +# gh workflow run adhoc-mac-build.yml --ref main -f ref=my-branch -f label=wasm-terminal +# +# Leaving the ref field empty builds whatever "Use workflow from" is set to, which +# is what someone who only touched that picker means. Naming the branch explicitly +# is still better: the workflow file is always read from the dispatch ref, so a +# branch carrying a stale copy of this file would otherwise run that copy. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the +# same GitHub App as hourly, additionally installed on orca-adhoc with +# Contents: Read and write. The secret names below are historical — one App, one +# private key, both dev-channel repos — and rotating it stays a single operation. +# Provision with `bash config/scripts/setup-hourly-release-token.sh`. + +on: + workflow_dispatch: + inputs: + ref: + # Why optional: the Actions UI already shows its own "Use workflow from" + # branch picker directly above this field, and picking a branch there is + # what most people will read as "build this". Defaulting to that branch + # makes the obvious action correct. Fill this in only to build a ref other + # than the one the workflow file itself is read from — normally leave the + # picker on main and name your branch here, so a stale copy of this + # workflow on an old branch is not what runs. + description: 'Branch, tag, or SHA to build (default: the branch selected above)' + required: false + default: '' + type: string + label: + description: 'Short name shown in the release title (default: the ref)' + required: false + default: '' + type: string + +permissions: + contents: read + +concurrency: + # Why keyed on the ref rather than global: two people cutting builds from two + # different branches at the same time is the ordinary case here, and serialising + # them would make each wait out the other's notary queue. Re-dispatching the + # *same* branch still queues, so a push mid-build cannot race itself. + group: adhoc-mac-build-${{ inputs.ref || github.ref_name }} + cancel-in-progress: false + +env: + ADHOC_REPO: stablyai/orca-adhoc + # Why age and not a count like hourly: this channel is low-volume and bursty, so + # a count would either hold one week's experiments forever or evict a build + # someone is still running after a busy afternoon. A month is well past the "few + # days" these exist for, and by then the branch has landed or been abandoned. + ADHOC_RETAIN_DAYS: 30 + +jobs: + build-adhoc-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. + timeout-minutes: 150 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout the requested ref + uses: actions/checkout@v6 + with: + # Why an input at all rather than just github.ref: the whole point is to + # build code that has not landed, and the workflow definition itself + # always comes from the dispatch ref — naming the branch here instead + # applies main's current copy of this file to an arbitrary branch. + ref: ${{ inputs.ref || github.ref_name }} + fetch-depth: 0 + # This job only reads stablyai/orca and never pushes; every write goes + # to the adhoc repo through a minted App token passed by env. Not + # persisting the checkout credential shrinks the blast radius if a build + # step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes an adhoc build installable over an existing + # Orca, so a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute adhoc version + id: adhoc + shell: bash + env: + REF: ${{ inputs.ref || github.ref_name }} + LABEL: ${{ inputs.label }} + run: | + set -euo pipefail + # Why this check: the version script is read from the branch being built, + # not from main, so a branch cut before the adhoc channel landed has no + # copy of it. Say that plainly instead of failing with a module-not-found. + if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then + echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel." + exit 1 + fi + echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" + ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \ + >"$RUNNER_TEMP/adhoc-identity.txt" + if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then + echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow." + exit 1 + fi + cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT" + + - name: Build app + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: adhoc builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted branch artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + # Why the token is minted here and not at the top: installation tokens live + # one hour, everything before this point writes nothing, and the notary round + # trip inside the publish step can be tens of minutes. Minting after the build + # starts the clock at the first call that actually uses it. + - name: Mint adhoc repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-adhoc + + - name: Create adhoc release + id: release + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: v${{ steps.adhoc.outputs.version }} + NAME: ${{ steps.adhoc.outputs.name }} + SHA: ${{ steps.adhoc.outputs.head_sha }} + REF: ${{ inputs.ref || github.ref_name }} + # Via env, not inline `${{ }}`: both land inside a shell string, and an + # expression expanded there is substituted before bash parses the line + # (zizmor: template-injection). + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + short_sha="${SHA:0:12}" + # Why create it up front: electron-builder then uploads into a known tag + # rather than inferring one from package.json. + # + # Why --draft: everything between here and the manifest check is a window + # where the release exists but has no installable assets. A draft is + # absent from the releases list and from listReleaseBuilds, so a job that + # dies in that window — including a hard kill by the job timeout, which + # runs no cleanup step at all — leaves something invisible rather than a + # tag the picker offers and the download 404s on. + gh release create "$TAG" \ + --repo "$ADHOC_REPO" \ + --title "$NAME" \ + --draft \ + --notes "Adhoc macOS dev build of \`$REF\` at commit \`$short_sha\`. + + Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA), cut by @$ACTOR. + + **Unlanded and unvetted.** This is somebody's branch, not main. No tests + ran. Signed and notarized like a release, so it installs through Orca's + in-app updater and opens without a Gatekeeper prompt — but the branch may + never merge, and this build is deleted after $ADHOC_RETAIN_DAYS days." + echo "tag=$TAG" >>"$GITHUB_OUTPUT" + + - name: Publish adhoc macOS artifacts + uses: nick-fields/retry@v4 + with: + # Why 45: an attempt is pack + notarize + upload, and the notary queue is + # the unbounded part. Two attempts, because a failed adhoc build has a + # person waiting on it who can simply dispatch again. + timeout_minutes: 45 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_ADHOC=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-adhoc. + GH_TOKEN: ${{ steps.app_token.outputs.token }} + ORCA_ADHOC_BUILD_VERSION: ${{ steps.adhoc.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.adhoc.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$ADHOC_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Adhoc draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Adhoc draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + NAME: ${{ steps.adhoc.outputs.name }} + run: | + set -euo pipefail + # --title again: electron-builder resolves this draft by tag and may + # rewrite its title on upload. Re-asserting here means the name the + # picker reads is the one composed above, whatever it did in between. + gh release edit "$TAG" --repo "$ADHOC_REPO" --draft=false --prerelease --title "$NAME" + echo "Published $TAG as \"$NAME\"" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name. Gated on publish_live not having succeeded so a later + # failure (the prune step) cannot delete a release that already went live and + # that people may already be installing. Why cancelled() too: a run stopped + # from the Actions UI is not a failure(), so without it a manual cancel + # mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$ADHOC_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune expired adhoc releases + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + # Why compute the cutoff in bash rather than with jq's `now`: this runs + # once per dispatch, and a fixed epoch makes the threshold visible in the + # log when someone asks where their build went. + cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 )) + echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')" + # --cleanup-tag so pruning does not leave orphan tags with no release or + # assets attached. Drafts are excluded: a stale draft is the failure + # path's business, not the retention window's. + stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \ + --jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + echo "Pruning $tag" + gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 7576fabc6a0..ebe544436a6 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -7,9 +7,11 @@ name: Hourly macOS Dev Build # - macOS only. Other platforms keep using RC/stable. # - No tests, no lint, no e2e. This channel trades safety for latency; PR CI # and release-cut remain the gates that matter. -# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's -# signature, not its notarization, so in-place updates still work while the -# ~10min notary round trip is skipped 24x a day. +# - Signed AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every hourly reads as a new client and silently loses file access +# under Documents/Desktop/Downloads — 24 times a day. # # Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main # repo's releases atom feed exposes only its 10 newest entries, so 24 hourly @@ -24,10 +26,14 @@ name: Hourly macOS Dev Build # HOURLY_RELEASE_APP_ID the App's numeric id # HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key # -# Installation tokens live one hour, which is ample here: this job skips tests, -# notarization, and Windows signing entirely, so a run is pack + upload and lands -# well inside that. If one ever did overrun, the release is still an unpublished -# draft at that point, so nothing user-visible breaks. +# Installation tokens live one hour, which is why this mints twice. Install and +# build need no token at all, and notarization can hold the publish step for tens +# of minutes; minting again once the build is done starts the clock at the first +# call that actually uses it rather than burning a third of it on `pnpm install`. +# A pathological retry can still outrun the second token, but the release is an +# unpublished draft until the manifest check passes, so the damage is a stranded +# invisible draft — and the build-number query counts drafts, so it holds its +# number and the next run does not reuse it. on: schedule: @@ -57,11 +63,11 @@ jobs: build-hourly-mac: if: github.repository == 'stablyai/orca' runs-on: blacksmith-6vcpu-macos-15 - # Why 120: it must exceed the worst case the retry budgets below can produce - # (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or # the job is killed mid-retry and no cleanup step runs at all. A typical run - # is far shorter — there is no notarization, Windows signing, or test phase. - timeout-minutes: 120 + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 env: NODE_OPTIONS: --max-old-space-size=4096 steps: @@ -212,12 +218,27 @@ jobs: # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + # Why a second mint: everything from here on writes to the hourly repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint hourly repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-hourly + - name: Create hourly release id: release if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: v${{ steps.hourly.outputs.version }} NAME: ${{ steps.hourly.outputs.name }} SHA: ${{ steps.freshness.outputs.head_sha }} @@ -244,30 +265,37 @@ jobs: Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA). - **Unvetted.** No tests ran. Signed but not notarized — installable - through Orca's in-app updater, but a manual download will be - Gatekeeper-quarantined." + **Unvetted.** No tests ran. Signed and notarized like a release, so it + installs through Orca's in-app updater and opens from a manual download + without a Gatekeeper prompt — but nothing here has been reviewed." echo "tag=$TAG" >>"$GITHUB_OUTPUT" - name: Publish hourly macOS artifacts if: steps.freshness.outputs.should_build == 'true' uses: nick-fields/retry@v4 with: - # Why lower than the release pipeline's 3x45: that budget is sized for - # notarization and Windows signing, neither of which runs here. An attempt - # is pack + upload only. - timeout_minutes: 25 + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed hourly costs an hour, and the next cron picks the same + # commit up, so a third attempt buys less than it costs in runner time. + timeout_minutes: 45 max_attempts: 2 retry_wait_seconds: 30 command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always env: # Why: electron-builder's github publisher targets the repo named in the # config; the token must therefore carry write access to orca-hourly. - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }} ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }} CSC_LINK: ${{ secrets.MAC_CERTS }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A @@ -277,7 +305,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail @@ -307,7 +335,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} NAME: ${{ steps.hourly.outputs.name }} run: | @@ -333,7 +361,7 @@ jobs: steps.publish_live.outcome != 'success' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} TAG: ${{ steps.release.outputs.tag }} run: | set -uo pipefail @@ -346,7 +374,7 @@ jobs: if: steps.freshness.outputs.should_build == 'true' shell: bash env: - GH_TOKEN: ${{ steps.app_token.outputs.token }} + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} run: | set -euo pipefail # Why: --cleanup-tag so pruning does not leave orphan tags behind that diff --git a/.oxlintrc.json b/.oxlintrc.json index 9c64c75323f..2473b070017 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -27,6 +27,8 @@ "app-store-performance/no-identity-selector": "error", "app-store-performance/no-fresh-selector-result": "error", "no-fallthrough": "error", + "eslint/no-useless-call": "error", + "oxc/no-accumulating-spread": "error", "quadratic-buffer-concat/no-loop-carried-concat": "error", "react/jsx-no-duplicate-props": "error", "react/jsx-no-undef": "error", diff --git a/AGENTS.md b/AGENTS.md index 1bfc8a99e67..bfb1617f8ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX beha # Style ## Concise/Brief Non-obviosu comments ONLY * DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW") - * DO: BE CONCISE. 1 LINE if possible + * BE CONCISE. 1 LINE if possible ## Lint Rules: Do Not Disable Max Lines diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 39a6df73b05..e581cd7a44e 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -16,14 +16,25 @@ const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibilit const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') -// Why: hourly dev builds must carry the *release* identity — same bundle id and -// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed -// Orca. Only notarization is skipped, which in-place updates never check. +// Why: dev-channel builds must carry the *release* identity — same bundle id, +// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to +// swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly +const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' +const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION -const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined +const devChannelBuildVersion = isMacHourly + ? process.env.ORCA_HOURLY_BUILD_VERSION + : isMacAdhoc + ? process.env.ORCA_ADHOC_BUILD_VERSION + : undefined +// Why each dev channel gets its own repo rather than tagging into the main one: +// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a +// day would evict every stable/RC entry and strand users on a feed with nothing +// to install. Keeping adhoc separate from hourly too means a branch build cannot +// be picked up by someone who only meant to ride main. +const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', @@ -70,8 +81,8 @@ const winSpeechNativeResource = { module.exports = { appId, productName: 'Orca', - ...(hourlyBuildVersion - ? { extraMetadata: { version: hourlyBuildVersion } } + ...(devChannelBuildVersion + ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}), @@ -330,11 +341,14 @@ module.exports = { // explicit release path so production artifacts remain strict while dev // artifacts do not fail with broken ad-hoc launch behavior. hardenedRuntime: isMacRelease, - // Why: Squirrel.Mac validates the replacement bundle's signature, not its - // notarization, so hourly builds stay installable while skipping the ~10min - // notary round trip 24x a day. A manually-downloaded hourly zip will be - // Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel. - notarize: isMacRelease && !isMacHourly, + // Why dev builds notarize too, despite the ~10min notary round trip: TCC + // anchors a notarized Developer ID app's permission grants on identifier + + // team, which is cdhash-independent and so survives an update. Without a + // ticket there is no such stable identity, so every build reads as a + // different client — the grant row stays but stops matching, and file access + // under Documents/Desktop/Downloads fails with EPERM and no re-prompt. At 24 + // builds a day that revokes the user's grants faster than they can re-grant. + notarize: isMacRelease, extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('darwin'), @@ -474,11 +488,8 @@ module.exports = { publish: { provider: 'github', owner: 'stablyai', - // Why: hourly tags must never enter the main repo's releases atom feed — it - // exposes only the 10 newest entries, so 24 hourly tags a day would evict - // every stable/RC entry and strand users on a feed with nothing to install. - repo: isMacHourly ? 'orca-hourly' : 'orca', - releaseType: isMacHourly ? 'prerelease' : 'release' + repo: devChannelRepo ?? 'orca', + releaseType: devChannelRepo ? 'prerelease' : 'release' } } diff --git a/config/knip.json b/config/knip.json new file mode 100644 index 00000000000..a48793b7902 --- /dev/null +++ b/config/knip.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "src/main/index.ts", + "src/preload/index.ts", + "src/preload/browser-window-close.ts", + "src/main/daemon/daemon-entry.ts", + "src/main/plugins/plugin-host-entry.ts", + "src/main/computer/sidecar-entry.ts", + "src/main/speech/stt-worker.ts", + "src/main/warp-themes/warp-theme-parser-worker.ts", + "src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts", + "src/main/ipc/parcel-watcher-process-entry.ts", + "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", + "src/main/codex/codex-app-server-grant-entry.ts", + "src/main/agent-hooks/managed-agent-hook-controls.ts", + "src/main/claude-accounts/keychain.ts", + "src/renderer/src/main.tsx", + "src/renderer/src/popout.tsx", + "src/renderer/src/web/main.tsx", + "src/renderer/src/**/*.worker.ts", + "src/cli/index.ts", + "src/relay/relay.ts", + "src/relay/wsl-agent-hook-relay.ts", + "config/scripts/**/*.{mjs,cjs,js,ts}", + "build-plugins/**/*.ts", + "electron.vite.config.ts", + "vite.web.config.ts", + "config/vitest.config.ts", + "config/i18next.config.ts", + "config/electron-builder.config.cjs", + "config/oxlint-plugins/**/*.{js,mjs,ts}", + "tests/**/*.{ts,tsx,mjs}", + "**/*.test.{ts,tsx}" + ], + "project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"], + "ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"], + "ignoreDependencies": ["electron", "@types/*"], + "ignoreExportsUsedInFile": true, + "includeEntryExports": false +} diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 73aca76ac6f..3275e33987a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,7 +2,6 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline mobile/src/constants/marine-creatures.ts inline src/cli/handlers/automations.ts inline src/cli/handlers/orchestration.ts inline src/cli/help.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 80cb0d5865f..343ff1c6ac7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1271,6 +1271,128 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.layout-pty-ownership", + "title": "Restored terminal layouts retain one renderer owner per PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-electron-restore", + "surfaces": [ + "persisted terminal layout restore", + "daemon PTY reattach", + "terminal tab hide and reveal", + "xterm renderer ownership", + "remote terminal layout mirroring" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Provider-independent unit tests cover persisted replay, state-boundary authority transfer, remote-runtime mirroring, rootless layouts, repeated leaf ids, metadata repair, and 12,000 nested duplicate leaves. A two-launch macOS Electron journey covers a real surviving local-daemon PTY, persisted duplicate ownership, synchronized streaming output, and tab hide/reveal. Live Linux, Windows, SSH, WSL, and remote-runtime restore journeys remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11757", + "https://github.com/stablyai/orca/pull/11726" + ], + "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", + "oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "assertions": [ + "replays one surface when restored leaves point to the same PTY", + "reattaches one PTY when the split repeats its bound leaf id" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "keeps the active leaf and prunes the stale surface plus its metadata", + "is idempotent and preserves one owner across PTY and focus permutations" + ] + }, + { + "file": "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "normalizes duplicate PTY surfaces at the renderer state boundary", + "moves hydrated pane authority onto the retained PTY leaf" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": ["deduplicates mirrored leaves that claim the same remote PTY"] + }, + { + "file": "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts", + "assertions": ["repairs duplicate persisted PTY renderers before streaming tab reveal"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.33, + "summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.7, + "summary": "The two-launch Electron journey preserved one real daemon PTY and one renderer through duplicate-layout repair and post-relaunch tab reveal." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused ownership unit suite plus one prebuilt two-launch Electron journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic unit and Electron gates pass locally on macOS; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Orca 1.4.161 visibly strands and fragments the synchronized stream after restoring duplicate PTY renderers (https://github.com/user-attachments/assets/6917ac6f-9ba3-4f6f-a082-fb62932d511f). The post-resize capture remains corrupted, with duplicated lower rows and the terminal still stranded on the right; it is not recovery evidence (https://github.com/user-attachments/assets/32f7be79-0919-48c2-bcad-e4b7f1e14f2d). After the #11726 ownership repair, the equivalent stream remains continuous in one full-width renderer (https://github.com/user-attachments/assets/8c9cc5a2-dbe3-4348-b05c-f8268b98c476), and the final automated journey reports one owner at every layer after relaunch and tab reveal (https://github.com/user-attachments/assets/f19b46aa-e56f-476c-9e2c-e4c3a784c805)." + }, + "performanceBudget": { + "required": true, + "evidence": "Ownership normalization is bounded by layout size, uses iterative traversal, and passes a 12,000-leaf duplicate layout without recursive stack growth. The follow-up adds no production polling, renderer work, listeners, persistence scans, or retained payloads." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the live two-launch journey on Linux and Windows.", + "Add live SSH or remote-runtime duplicate-restore evidence before claiming provider-complete coverage." + ], + "knownGaps": [ + "Live Electron evidence currently covers macOS with a local daemon PTY only.", + "The live journey uses a git-backed test workspace; folder-workspace restoration currently relies on the same provider-independent normalization contracts rather than a separate Electron run.", + "The gate deterministically seeds the historical persisted-state shape; it does not depend on reproducing the unknown UI sequence that originally wrote duplicate ownership.", + "The visual old-release evidence and the structural current-version oracle are separate runs because the current test harness did not exist in Orca 1.4.161." + ], + "demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization." + }, { "id": "terminal-session.kill-all-surface-cleanup", "title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings", @@ -3567,13 +3689,13 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -3671,6 +3793,7 @@ "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ "forwards facts over the shared client-event stream without a desktop renderer", + "bounds decorative title delivery per paired client without reducing local frames", "prefers provider history over a partial headless mirror for requested snapshots", "falls back to the available mirror when authoritative provider history is unavailable", "bounds a hung authoritative provider acquisition and reuses its fallback" @@ -3690,7 +3813,11 @@ }, { "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", - "assertions": ["restores configured paired scrollback after an ordinary park reveal"] + "assertions": [ + "pauses capable paired output while hidden and restores exactly on reveal", + "locally gates hidden paired output when a legacy host cannot pause it", + "restores configured paired scrollback after an ordinary park reveal" + ] }, { "file": "src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts", @@ -3739,6 +3866,33 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-02", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", + "result": "passed", + "durationSeconds": 18.71, + "summary": "Seventeen focused files passed 2,044 tests with one existing skip, including the 64-PTY decorative-title bound, per-client late subscription, semantic-fact preservation, and the true no-output-pause legacy client shape." + }, + { + "date": "2026-08-02", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 13.7, + "summary": "The isolated headed desktop host and encrypted paired web client kept hidden renderer work bounded, ordinary-parked five of six real PTYs, restored the authoritative tail once, preserved PTY identity, and resumed live input/output." + }, + { + "date": "2026-08-02", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 31.4, + "summary": "Both isolated headless `orca serve` scenarios passed: ordinary parking restored authoritative history and cold activation mounted only the visible paired terminal." + }, { "date": "2026-07-30", "runner": "local", @@ -3840,11 +3994,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", @@ -3858,9 +4012,10 @@ "The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.", "Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.", "Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.", + "The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.", "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], - "demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." + "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, { "id": "terminal-performance.no-hot-list-sessions", @@ -7354,6 +7509,129 @@ ], "demotionRule": "Disable the probe if it exceeds its per-reveal budget or produces false-positive anomaly noise." }, + { + "id": "terminal-render.atlas-recovery-fanout", + "title": "Terminal atlas recovery stays bounded to visible renderers", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit-and-electron", + "surfaces": [ + "WebGL rendering", + "document visibility", + "hidden terminal output", + "paired terminal traffic" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers and hidden synchronized/TUI output. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7054", + "https://github.com/stablyai/orca/pull/7604" + ], + "invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Genuine OS resume remains a heavy recovery trigger.", + "oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts", + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": ["preserves the glyph atlas when a fullscreen Space becomes visible"] + }, + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts", + "assertions": [ + "preserves WebGL texture atlases when the active terminal document becomes visible" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "assertions": ["bounds atlas recovery to visible managers"] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "defers hidden synchronized-output atlas recovery until reveal", + "defers hidden high-confidence TUI redraw recovery until reveal", + "advances hidden rewrite state without scheduling atlas recovery" + ] + }, + { + "file": "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts", + "assertions": [ + "preserves the WebGL atlas and keeps terminal text painted after document visibility resumes" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 26, + "summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 18, + "summary": "The changed-spec CI topology retained both real WebGL terminal panes with zero atlas clears after a deterministic document visibility cycle." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 23, + "summary": "Two real WebGL terminal panes retained painted glyphs with zero atlas clears after a deterministic document visibility cycle. BrowserWindow.hide did not change document visibility in the harness, so the test used its explicit visibility-event fallback." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "focused renderer contracts plus one prebuilt Electron visibility test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The focused unit oracle and one local Electron run passed; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass." + }, + "performanceBudget": { + "required": true, + "evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work; no polling, cooldown, provider call, or parking change is added." + }, + "promotionCriteria": [ + "Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.", + "Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.", + "Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage." + ], + "knownGaps": [ + "The Electron harness used a deterministic visibility-event fallback because BrowserWindow.hide did not change document.visibilityState.", + "No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.", + "The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold." + ], + "demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic." + }, { "id": "terminal-render.atlas-identity-invalidation", "title": "A WebGL atlas identity change rebuilds cached glyph vertices", diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs new file mode 100644 index 00000000000..db7c95676d7 --- /dev/null +++ b/config/scripts/adhoc-build-version.mjs @@ -0,0 +1,106 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' + +/** Long enough to name a feature, short enough that a picker row stays readable. */ +export const ADHOC_LABEL_MAX_LENGTH = 32 + +/** + * `1.4.160-adhoc.20260728140533` — UTC to the second, so tags sort + * chronologically by semver and every build is uniquely versioned. + * + * Why seconds when hourly uses minutes: hourly runs under a concurrency group and + * cannot overlap itself. Adhoc builds are dispatched on demand, so two people + * cutting from different branches in the same minute is ordinary — and a + * minute-resolution tag would collide and fail the second build after its whole + * pack-and-notarize run. + */ +export function createAdhocBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Adhoc build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()) + ].join('') + // Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc + // build semver-NEWER than the RC it was cut from, letting an ordinary + // RC-channel check offer an unreviewed branch build to RC users. Stripping to + // the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-adhoc.${stamp}` +} + +/** + * Turns a dispatch input into a label safe to put in a release title. + * + * The input is free text from whoever ran the workflow, so it cannot be trusted + * to stay inside the title's shape: a stray `•` would forge a field separator, + * and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both + * collapse to `-` here, which is why this replaces rather than rejects. + */ +export function normalizeAdhocLabel(label) { + const cleaned = String(label ?? '') + // `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the + // prefix is noise in a title where every row is already a branch build. + .replace(/^(?:refs\/heads\/|origin\/)/, '') + .replace(/[^\p{L}\p{N}._/-]+/gu, ' ') + .trim() + .replace(/\s+/g, '-') + .slice(0, ADHOC_LABEL_MAX_LENGTH) + // Truncation can land mid-separator, leaving a title ending in `-` or `/`. + .replace(/[-._/]+$/, '') + if (!cleaned) { + throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`) + } + return cleaned +} + +/** + * `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release + * title, shown verbatim in both the GitHub releases list and the build picker. + * + * Why the label sits where hourly puts its build number: several adhoc builds + * from different branches coexist in the channel, so the picker needs the branch + * to tell them apart. A counter would say nothing about which one to pick. + */ +export function formatAdhocReleaseName(version, label, commit, date) { + return [ + version.split('-')[0], + normalizeAdhocLabel(label), + formatReleaseTitleTimestamp(date), + commit.slice(0, 7) + ].join(' • ') +} + +export function getAdhocBuildIdentity(now = new Date(), label = '') { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + const version = createAdhocBuildVersion(packageJson.version, now) + return { + commit, + version, + label: normalizeAdhocLabel(label), + name: formatAdhocReleaseName(version, label, commit, now) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '') + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write( + `version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n` + ) +} diff --git a/config/scripts/adhoc-build-version.test.mjs b/config/scripts/adhoc-build-version.test.mjs new file mode 100644 index 00000000000..4824720f2fc --- /dev/null +++ b/config/scripts/adhoc-build-version.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + createAdhocBuildVersion, + formatAdhocReleaseName, + normalizeAdhocLabel +} from './adhoc-build-version.mjs' +import { createHourlyBuildVersion } from './hourly-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createAdhocBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp to the second', () => { + expect(createAdhocBuildVersion('1.4.160', new Date('2026-07-28T04:05:09Z'))).toBe( + '1.4.160-adhoc.20260728040509' + ) + }) + + // Why seconds matter: adhoc builds are dispatched on demand, so two people + // cutting from different branches inside the same minute is ordinary. At + // hourly's resolution the second one would collide on the tag and die after its + // whole pack-and-notarize run. + it('distinguishes two builds cut in the same minute', () => { + const first = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:02Z')) + const second = createAdhocBuildVersion('1.4.160', new Date('2026-07-28T14:05:41Z')) + expect(first).not.toBe(second) + expect(compareAppVersions(first, second)).toBeLessThan(0) + }) + + it('drops an in-flight rc tail so adhoc builds never outrank the rc series', () => { + const version = createAdhocBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z')) + expect(version).toBe('1.4.160-adhoc.20260728140000') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions(version, '1.4.160')).toBeLessThan(0) + }) + + // Why this ordering is load-bearing: an adhoc build is somebody's unlanded + // branch. It must sit below every other channel of the same base version so no + // routine check can walk a developer onto one — only an explicit pinned jump. + it('sorts below the hourly build of the same base version', () => { + expect( + compareAppVersions( + createAdhocBuildVersion('1.4.160', new Date('2026-07-28T23:59:59Z')), + createHourlyBuildVersion('1.4.160', new Date('2026-07-28T00:00:00Z')) + ) + ).toBeLessThan(0) + }) + + it('rejects invalid input', () => { + expect(() => createAdhocBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createAdhocBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) + +describe('normalizeAdhocLabel', () => { + it('keeps an ordinary branch name intact', () => { + expect(normalizeAdhocLabel('wasm-terminal')).toBe('wasm-terminal') + expect(normalizeAdhocLabel('nwparker/wasm-terminal')).toBe('nwparker/wasm-terminal') + }) + + it('strips the ref prefixes a dispatch input tends to arrive with', () => { + expect(normalizeAdhocLabel('refs/heads/wasm-terminal')).toBe('wasm-terminal') + expect(normalizeAdhocLabel('origin/wasm-terminal')).toBe('wasm-terminal') + }) + + // Why replaced rather than rejected: the label is free text from whoever ran + // the workflow. A `•` would forge the title's field separator and a newline + // would break the `$GITHUB_OUTPUT` line the workflow parses. + it('neutralizes characters that would corrupt the title or the output line', () => { + expect(normalizeAdhocLabel('a • b')).toBe('a-b') + expect(normalizeAdhocLabel('a\nname=evil')).toBe('a-name-evil') + expect(normalizeAdhocLabel(' spaced out ')).toBe('spaced-out') + }) + + it('truncates without leaving a trailing separator', () => { + expect(normalizeAdhocLabel('a'.repeat(80))).toHaveLength(32) + expect(normalizeAdhocLabel(`${'a'.repeat(31)}-tail`)).toBe('a'.repeat(31)) + }) + + it('rejects a label with nothing usable in it', () => { + expect(() => normalizeAdhocLabel('')).toThrow(/no usable characters/) + expect(() => normalizeAdhocLabel(' ')).toThrow(/no usable characters/) + expect(() => normalizeAdhocLabel('•••')).toThrow(/no usable characters/) + expect(() => normalizeAdhocLabel(null)).toThrow(/no usable characters/) + }) +}) + +describe('formatAdhocReleaseName', () => { + const name = (iso, label = 'wasm-terminal', commit = 'e698241abcde') => + formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso)) + + it('renders version, label, Pacific timestamp, and short sha', () => { + expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241') + }) + + // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so + // the offset between them is not a constant. A test pinned to one season would + // pass all summer and start failing in November. + it('follows the Pacific offset across DST', () => { + expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ') + expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ') + }) + + it('sanitizes the label before it reaches the title', () => { + expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe( + '1.4.163 • fix-now • 07-31 13:54 • e698241' + ) + }) + + it('rejects an invalid timestamp', () => { + expect(() => formatAdhocReleaseName('1.4.163', 'x', 'abcdefg', new Date('nope'))).toThrow( + /invalid/ + ) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index b9c4a499118..b7ea67875f9 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -21,8 +21,10 @@ const { const MUTABLE_BUILD_ENV = [ 'ORCA_MAC_HOURLY', + 'ORCA_MAC_ADHOC', 'ORCA_MAC_RELEASE', 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_ADHOC_BUILD_VERSION', 'ORCA_LOCAL_BUILD_VERSION' ] @@ -51,6 +53,7 @@ function withEnv(env, assert) { } const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) +const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { @@ -313,15 +316,18 @@ describe('electron-builder config', () => { }) }) - // Why: notarization is the one release step hourly skips; in-place updates never - // check it, and 24 notary round trips a day is the cost being avoided. - it('skips notarization only for hourly builds', () => { + // Why hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(false) + expect(config.mac.notarize).toBe(true) }) withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { expect(config.mac.notarize).toBe(true) }) + expect(electronBuilderConfig.mac.notarize).toBe(false) }) // Why: the main repo's releases atom feed exposes only its 10 newest entries. @@ -346,6 +352,40 @@ describe('electron-builder config', () => { ) }) + // Why adhoc carries the identical mac identity to hourly: it installs over a + // real Orca through the same updater path, so the same signing and the same TCC + // argument apply. Only the destination repo differs. + it('builds adhoc artifacts with the release identity and its own repo', () => { + withAdhocEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) + }) + }) + + it('stamps adhoc packages with the adhoc version', () => { + withEnv( + { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) + } + ) + }) + + // Why: the two dev channels share every packaging decision except where they + // publish, so a future edit that collapses them must not also collapse the + // repos — a branch build landing in orca-hourly would be offered to everyone + // riding main. + it('keeps the two dev channels on separate repos', () => { + withHourlyEnv((hourly) => { + withAdhocEnv((adhoc) => { + expect(hourly.publish.repo).not.toBe(adhoc.publish.repo) + }) + }) + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 8d8dafc22d4..f4545af086b 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -1,6 +1,7 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' /** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically * by semver and every build is uniquely versioned. */ @@ -28,41 +29,18 @@ export function createHourlyBuildVersion(baseVersion, date) { return `${match[1]}-hourly.${stamp}` } -const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles' - /** * `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown * verbatim in both the GitHub releases list and the in-app build picker. - * - * Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a - * local one would repeat an hour at every DST fall-back, making two distinct - * builds compare equal. The title is only ever read, so it uses the timezone the - * people reading it are in. The two therefore disagree by the current offset. */ export function formatHourlyReleaseName(version, buildNumber, commit, date) { if (!Number.isInteger(buildNumber) || buildNumber < 1) { throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`) } - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - throw new Error('Hourly build timestamp is invalid.') - } - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-US', { - timeZone: RELEASE_NAME_TIME_ZONE, - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - // Why h23 rather than hour12: false: some ICU builds render midnight as 24. - hourCycle: 'h23' - }) - .formatToParts(date) - .map((part) => [part.type, part.value]) - ) return [ version.split('-')[0], String(buildNumber).padStart(2, '0'), - `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`, + formatReleaseTitleTimestamp(date), commit.slice(0, 7) ].join(' • ') } diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 4618f052f1e..9b40ad61f50 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -5038,5 +5038,14 @@ }, "settings.appearance.statusBar.sshToggleDescription": { "ko": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다." + }, + "auto.components.right.sidebar.AiVaultPanelControls.selectAllAgents": { + "ko": "모두 선택" + }, + "auto.components.right.sidebar.AiVaultPanelControls.clearAgents": { + "ko": "모두 해제" + }, + "auto.components.right.sidebar.AiVaultPanel.noAgentsSelected": { + "ko": "선택된 에이전트가 없습니다" } } diff --git a/config/scripts/marine-creatures-parity.test.mjs b/config/scripts/marine-creatures-parity.test.mjs deleted file mode 100644 index 33b51a5d3bb..00000000000 --- a/config/scripts/marine-creatures-parity.test.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' - -function readCreatureNames(path) { - const source = readFileSync(path, 'utf8') - return Array.from(source.matchAll(/^ '([^']+)',?$/gm), (match) => match[1]) -} - -describe('marine creature corpus mirrors', () => { - it('keeps the mobile mirror in parity with the shared corpus', () => { - const sharedNames = readCreatureNames('src/shared/marine-creatures.ts') - const mobileNames = readCreatureNames('mobile/src/constants/marine-creatures.ts') - - expect(mobileNames).toEqual(sharedNames) - }) -}) diff --git a/config/scripts/release-title-timestamp.mjs b/config/scripts/release-title-timestamp.mjs new file mode 100644 index 00000000000..56d3608ccaf --- /dev/null +++ b/config/scripts/release-title-timestamp.mjs @@ -0,0 +1,30 @@ +const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles' + +/** + * `07-31 13:54` — the timestamp segment of a dev build's release title, shown + * verbatim in both the GitHub releases list and the in-app build picker. + * + * Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and + * a local one would repeat an hour at every DST fall-back, making two distinct + * builds compare equal. A title is only ever read, so it uses the timezone the + * people reading it are in. The two therefore disagree by the current offset. + */ +export function formatReleaseTitleTimestamp(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Release title timestamp is invalid.') + } + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone: RELEASE_NAME_TIME_ZONE, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + // Why h23 rather than hour12: false: some ICU builds render midnight as 24. + hourCycle: 'h23' + }) + .formatToParts(date) + .map((part) => [part.type, part.value]) + ) + return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}` +} diff --git a/config/scripts/run-internal-dev-setup.mjs b/config/scripts/run-internal-dev-setup.mjs index e585e4a775d..5076017339e 100644 --- a/config/scripts/run-internal-dev-setup.mjs +++ b/config/scripts/run-internal-dev-setup.mjs @@ -20,11 +20,27 @@ function quoteWindowsArg(value) { return `"${value.replace(/"/g, '""')}"` } +// Why: under a Git Bash setup runner, Orca exports ORCA_WORKTREE_PATH in MSYS form (/c/...), which +// cmd.exe cannot resolve. This is the migration pattern for any setup script feeding a native exe. +function posixShellPathToNativeWindowsPath(value) { + const driveMatch = value.match(/^\/([A-Za-z])\/(.*)$/) + if (driveMatch) { + return `${driveMatch[1].toUpperCase()}:\\${driveMatch[2].replace(/\//g, '\\')}` + } + return value +} + function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) { if (platform === 'win32') { + const nativeWorktreePath = posixShellPathToNativeWindowsPath(worktreePath) spawn( env.ComSpec || 'cmd.exe', - ['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`], + [ + '/d', + '/s', + '/c', + `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(nativeWorktreePath)}` + ], { stdio: 'inherit', windowsVerbatimArguments: true diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh new file mode 100755 index 00000000000..d239d930717 --- /dev/null +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Creates stablyai/orca-adhoc and grants the existing release App write access to +# it, so adhoc-mac-build.yml can publish there. +# +# Why a separate repo rather than reusing orca-hourly: an adhoc build is somebody's +# unlanded branch. Sharing hourly's repo would put branch builds in the list a +# developer riding main sees, and the two are different levels of unvetted. +# +# Why no secrets are set here: the adhoc workflow reuses the same GitHub App as +# hourly — one App id, one private key, one thing to rotate. This script only has +# to widen that App's installation to cover the new repo. +# +# Run once, after config/scripts/setup-hourly-release-token.sh: +# bash config/scripts/setup-adhoc-release-repo.sh +# +set -euo pipefail + +ORG="stablyai" +ADHOC_REPO="$ORG/orca-adhoc" +MAIN_REPO="$ORG/orca" +APP_SLUG="orca-hourly-release" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +if gh api "repos/$ADHOC_REPO" --jq '.full_name' >/dev/null 2>&1; then + echo "$ADHOC_REPO already exists." +else + echo "Creating $ADHOC_REPO..." + # Why public: the in-app updater fetches release assets unauthenticated, exactly + # as it does for orca-hourly. A private repo would 404 for every client. + # + # Why the features are off: this repo holds releases and nothing else. Leaving + # issues open invites bug reports against a branch build in a repo nobody + # watches, where they are simply lost. + gh repo create "$ADHOC_REPO" \ + --public \ + --description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \ + --disable-issues \ + --disable-wiki || + fail "Could not create $ADHOC_REPO." +fi + +echo +echo "Granting $APP_SLUG access to $ADHOC_REPO..." + +# Why attempt the API before printing instructions: an org owner can do this in +# one call. Everyone else gets a 403 and the manual path below — GitHub does not +# let a mere admin widen an App's repository selection. +INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \ + --jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)" +REPO_ID="$(gh api "repos/$ADHOC_REPO" --jq '.id' 2>/dev/null || true)" + +GRANTED=false +if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then + if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then + GRANTED=true + echo "Done — $APP_SLUG can now write to $ADHOC_REPO." + fi +fi + +if [[ "$GRANTED" != "true" ]]; then + # Why no automated check afterwards: the endpoints that report an App's + # repository access (repos/*/installation, user/installations/*/repositories) + # both reject an ordinary `gh auth login` token, so any "verified" this script + # printed would be guesswork. The smoke test below is the real check. + cat < $APP_SLUG + 3. Repository access -> Only select repositories -> add $ADHOC_REPO + (keep orca-hourly selected; both dev channels use this one App) + 4. Save. +EOF +fi + +echo +echo "Smoke-test the pipeline (after this merges):" +echo " gh workflow run adhoc-mac-build.yml --repo $MAIN_REPO --ref main \\" +echo " -f ref= -f label=" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index f21ce7b5bb7..3d846bc01d7 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -8,6 +8,10 @@ # — no yearly rotation — and it belongs to the org rather than to the person who # created it, so it survives that person leaving. # +# The same App also serves adhoc-mac-build.yml, which reads these same two +# secrets: one credential, one rotation, both dev channels. Widening it to cover +# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job. +# # The key is read from a file and piped straight into `gh secret set`. It is never # echoed, never passed as a command-line argument (argv is world-readable via # `ps`), and never copied anywhere on disk. diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index c2e937384ec..df51a8732ac 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 12m + + downloads: 13m @@ -15,7 +15,7 @@ downloads downloads - 12m - 12m + 13m + 13m diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 1054d7eb5e6..a504627c968 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -14,7 +14,7 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../../../src/transport/host-store' import { useHostClient } from '../../../src/transport/client-context' import { colors, spacing } from '../../../src/theme/mobile-theme' -import { styles } from './accounts-screen-styles' +import { styles } from '../../../src/accounts/mobile-accounts-screen-styles' import { useNow } from '../../../src/hooks/use-now' import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' import { diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 431cd8d2150..7ff20834eef 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -268,8 +268,8 @@ import { reconcileMobileSessionCreateWarningState } from '../../../../src/session/mobile-session-create-warning-state' import { colors, spacing } from '../../../../src/theme/mobile-theme' -import { styles } from './mobile-session-styles' -import { QuickCommandsTabButton } from './QuickCommandsTabButton' +import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' +import { styles } from '../../../../src/session/mobile-session-styles' import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' import type { DiffLineRowProps, @@ -292,11 +292,11 @@ import type { TerminalCreateResult, TerminalGestureInputBucket, TerminalGestureInputQueue -} from './mobile-session-route-types' +} from '../../../../src/session/mobile-session-route-types' import { createMobileTranslator, t } from '@/i18n/mobile-i18n' +import { addRefreshTimer, type RefreshTimerLifecycle } from '@/session/session-refresh-timer' const tr = createMobileTranslator('worktreeId') -import { addRefreshTimer, type RefreshTimerLifecycle } from '@/session/session-refresh-timer' const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 diff --git a/mobile/app/h/[hostId]/accounts-screen-styles.ts b/mobile/src/accounts/mobile-accounts-screen-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/accounts-screen-styles.ts rename to mobile/src/accounts/mobile-accounts-screen-styles.ts index 4a987b7a442..65074f340ae 100644 --- a/mobile/app/h/[hostId]/accounts-screen-styles.ts +++ b/mobile/src/accounts/mobile-accounts-screen-styles.ts @@ -1,5 +1,5 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' +import { colors, spacing, typography, radii } from '../theme/mobile-theme' export const styles = StyleSheet.create({ container: { diff --git a/mobile/src/components/MobileAgentIcon.tsx b/mobile/src/components/MobileAgentIcon.tsx index 6c47fce7a5b..c5557ab3934 100644 --- a/mobile/src/components/MobileAgentIcon.tsx +++ b/mobile/src/components/MobileAgentIcon.tsx @@ -32,7 +32,7 @@ function OmpIcon({ size = 16 }: { size?: number }) { - + diff --git a/mobile/src/components/mobile-agent-icon-gradient.test.ts b/mobile/src/components/mobile-agent-icon-gradient.test.ts new file mode 100644 index 00000000000..8e2781edfb2 --- /dev/null +++ b/mobile/src/components/mobile-agent-icon-gradient.test.ts @@ -0,0 +1,65 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileAgentIcon } from './MobileAgentIcon' + +vi.mock('react-native', () => ({ + Image: 'Image', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Terminal: 'Terminal' +})) + +vi.mock('react-native-svg', () => ({ + default: 'Svg', + Defs: 'Defs', + G: 'G', + LinearGradient: 'LinearGradient', + Path: 'Path', + Stop: 'Stop' +})) + +vi.mock('./mobile-agent-icon-assets', () => ({ + MOBILE_AGENT_ICON_ASSETS: {} +})) + +vi.mock('./AgentIcons', () => ({ + ClaudeIcon: 'ClaudeIcon', + OpenAIIcon: 'OpenAIIcon' +})) + +describe('MobileAgentIcon OMP gradient', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('uses valid SVG gradient stop offsets', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + await act(async () => { + renderer = create(createElement(MobileAgentIcon, { agentId: 'omp' })) + }) + consoleError.mockRestore() + + expect(renderer.root.findAllByType('Stop').map((stop) => stop.props.offset)).toEqual([ + '0', + '0.5', + '1' + ]) + }) +}) diff --git a/mobile/src/components/worktree-name-suggestion.ts b/mobile/src/components/worktree-name-suggestion.ts index 5ac28bbddf4..4e84eaae097 100644 --- a/mobile/src/components/worktree-name-suggestion.ts +++ b/mobile/src/components/worktree-name-suggestion.ts @@ -1,4 +1,4 @@ -import { MARINE_CREATURES } from '../constants/marine-creatures' +import { MARINE_CREATURES } from '../../../src/shared/marine-creatures' // Why: matches the desktop fallback in // src/renderer/src/components/sidebar/worktree-name-suggestions.ts. The diff --git a/mobile/src/constants/marine-creatures.ts b/mobile/src/constants/marine-creatures.ts deleted file mode 100644 index fd245bd7422..00000000000 --- a/mobile/src/constants/marine-creatures.ts +++ /dev/null @@ -1,557 +0,0 @@ -/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */ -export const MARINE_CREATURES = [ - 'Nautilus', - 'Seahorse', - 'Starfish', - 'Coral', - 'Narwhal', - 'Jellyfish', - 'Octopus', - 'Manta', - 'Dolphin', - 'Manatee', - 'Cuttlefish', - 'Anemone', - 'Urchin', - 'Triton', - 'Nudibranch', - 'Coelacanth', - 'Oarfish', - 'Beluga', - 'Dugong', - 'Porpoise', - 'Stingray', - 'Hammerhead', - 'Wobbegong', - 'Sawfish', - 'Chimaera', - 'Anglerfish', - 'Viperfish', - 'Dragonfish', - 'Axolotl', - 'Otter', - 'Seadragon', - 'Seahare', - 'Squid', - 'Argonaut', - 'Sponge', - 'Barnacle', - 'Cowrie', - 'Guitarfish', - 'Lamprey', - 'Isopod', - 'Lionfish', - 'Clownfish', - 'Angelfish', - 'Butterflyfish', - 'Parrotfish', - 'Pufferfish', - 'Moonfish', - 'Firefish', - 'Unicornfish', - 'Rainbowfish', - 'Betta', - 'Discus', - 'Arowana', - 'Koi', - 'Piranha', - 'Barracuda', - 'Moray', - 'Sunfish', - 'Lanternfish', - 'Archerfish', - 'Mudskipper', - 'Hatchetfish', - 'Knifefish', - 'Leaffish', - 'Glassfish', - 'Ropefish', - 'Bichir', - 'Tigerfish', - 'Cardinalfish', - 'Lungfish', - 'Opah', - 'Frogfish', - 'Stonefish', - 'Cutlassfish', - 'Paddlefish', - 'Arapaima', - 'Mandarin', - 'Blobfish', - 'Thresher', - 'Vaquita', - 'Pipefish', - 'Guppy', - 'Tetra', - 'Danio', - 'Cichlid', - 'Oscar', - 'Gourami', - 'Killifish', - 'Rasbora', - 'Pleco', - 'Goldfish', - 'Molly', - 'Platy', - 'Barb', - 'Loach', - 'Medaka', - 'Pacu', - 'Filefish', - 'Boxfish', - 'Cowfish', - 'Surgeonfish', - 'Damselfish', - 'Wrasse', - 'Goby', - 'Blenny', - 'Conger', - 'Sculpin', - 'Darter', - 'Remora', - 'Pilotfish', - 'Trumpetfish', - 'Cornetfish', - 'Jawfish', - 'Toadfish', - 'Pearlfish', - 'Driftfish', - 'Lumpfish', - 'Snailfish', - 'Stickleback', - 'Halfbeak', - 'Snipefish', - 'Pencilfish', - 'Snakehead', - 'Tripletail', - 'Lookdown', - 'Sweetlips', - 'Squirrelfish', - 'Soldierfish', - 'Rabbitfish', - 'Hawkfish', - 'Bannerfish', - 'Batfish', - 'Chromis', - 'Anthias', - 'Fusilier', - 'Sweeper', - 'Emperor', - 'Ponyfish', - 'Kelpfish', - 'Weever', - 'Pearlside', - 'Opaleye', - 'Ballyhoo', - 'Needlefish', - 'Triggerfish', - 'Gar', - 'Tarpon', - 'Sailfish', - 'Conch', - 'Walrus', - 'Seal', - 'Penguin', - 'Hagfish', - 'Gulper', - 'Hydra', - 'Krill', - 'Salp', - 'Tunicate', - 'Crinoid', - 'Polyp', - 'Limpet', - 'Whelk', - 'Bowfin', - 'Minnow', - 'Gudgeon', - 'Goldeye', - 'Mooneye', - 'Soapfish', - 'Leatherjacket', - 'Rudderfish', - 'Sabrefish', - 'Puffin', - 'Albatross', - 'Osprey', - 'Pelican', - 'Petrel', - 'Gannet', - 'Tern', - 'Cormorant', - 'Leatherback', - 'Hawksbill', - 'Loggerhead', - 'Dottyback', - 'Basslet', - 'Gramma', - 'Hamlet', - 'Hogfish', - 'Dartfish', - 'Foxface', - 'Shrimpfish', - 'Pyrosome', - 'Seapen', - 'Medusa', - 'Candlefish', - 'Silverside', - 'Shiner', - 'Eelpout', - 'Madtom', - 'Shovelnose', - 'Sandlance', - 'Tubesnout', - 'Marlin', - 'Bonefish', - 'Dace', - 'Rudd', - 'Spirula', - 'Permit', - 'Grunt', - 'Trevally', - 'Croaker', - 'Drum', - 'Ladyfish', - 'Bullhead', - 'Torpedo', - 'Skate', - 'Hermit', - 'Fiddler', - 'Fireworm', - 'Tubeworm', - 'Amphipod', - 'Copepod', - 'Pteropod', - 'Seaslug', - 'Swordtail', - 'Bluegill', - 'Pickerel', - 'Redhorse', - 'Logperch', - 'Goblin', - 'Catshark', - 'Harlequin', - 'Murex', - 'Volute', - 'Wentletrap', - 'Nerite', - 'Stargazer', - 'Handfish', - 'Flyingfish', - 'Waspfish', - 'Skua', - 'Shearwater', - 'Guillemot', - 'Razorbill', - 'Auk', - 'Fulmar', - 'Murre', - 'Velvetfish', - 'Prowfish', - 'Sandperch', - 'Bobtail', - 'Chiton', - 'Featherstar', - 'Nereid', - 'Siren', - 'Kraken', - 'Selkie', - 'Leviathan', - 'Wolffish', - 'Wreckfish', - 'Horseshoe', - 'Orca', - 'Cachalot', - 'Rorqual', - 'Grampus', - 'Humpback', - 'Bowhead', - 'Finback', - 'Sealion', - 'Pinniped', - 'Cetacean', - 'Mako', - 'Porbeagle', - 'Dogfish', - 'Spurdog', - 'Tope', - 'Bonnethead', - 'Sixgill', - 'Sevengill', - 'Angelshark', - 'Houndshark', - 'Megamouth', - 'Cookiecutter', - 'Bramble', - 'Ghostshark', - 'Sandtiger', - 'Galeocerdo', - 'Nursehound', - 'Cownose', - 'Devilray', - 'Eagleray', - 'Butterflyray', - 'Numbfish', - 'Stingaree', - 'Mantaray', - 'Tuna', - 'Albacore', - 'Bonito', - 'Skipjack', - 'Wahoo', - 'Kingfish', - 'Mackerel', - 'Cero', - 'Dorado', - 'Mahimahi', - 'Escolar', - 'Pomfret', - 'Butterfish', - 'Cod', - 'Haddock', - 'Pollock', - 'Whiting', - 'Hake', - 'Ling', - 'Cusk', - 'Burbot', - 'Saithe', - 'Sablefish', - 'Lingcod', - 'Greenling', - 'Rockling', - 'Halibut', - 'Flounder', - 'Sole', - 'Plaice', - 'Turbot', - 'Brill', - 'Dab', - 'Fluke', - 'Megrim', - 'Sanddab', - 'Herring', - 'Sardine', - 'Anchovy', - 'Sprat', - 'Pilchard', - 'Menhaden', - 'Shad', - 'Alewife', - 'Salmon', - 'Trout', - 'Char', - 'Grayling', - 'Steelhead', - 'Kokanee', - 'Chinook', - 'Coho', - 'Sockeye', - 'Taimen', - 'Huchen', - 'Cisco', - 'Inconnu', - 'Vendace', - 'Whitefish', - 'Perch', - 'Bass', - 'Snapper', - 'Grouper', - 'Seabass', - 'Tilefish', - 'Bream', - 'Porgy', - 'Sheepshead', - 'Pinfish', - 'Scup', - 'Tautog', - 'Sander', - 'Zander', - 'Walleye', - 'Sauger', - 'Ruffe', - 'Comber', - 'Hind', - 'Coney', - 'Graysby', - 'Margate', - 'Pompano', - 'Amberjack', - 'Yellowtail', - 'Scad', - 'Runner', - 'Mullet', - 'Goatfish', - 'Threadfin', - 'Snook', - 'Barramundi', - 'Milkfish', - 'Mojarra', - 'Weakfish', - 'Corbina', - 'Queenfish', - 'Kahawai', - 'Tilapia', - 'Cunner', - 'Tuskfish', - 'Razorfish', - 'Tang', - 'Porcupinefish', - 'Burrfish', - 'Eel', - 'Wolfeel', - 'Gardeneel', - 'Ribboneel', - 'Cuskeel', - 'Catfish', - 'Cory', - 'Ratfish', - 'Elephantfish', - 'Goosefish', - 'Monkfish', - 'Sargassum', - 'Coffinfish', - 'Seadevil', - 'Scorpionfish', - 'Rockfish', - 'Cabezon', - 'Rosefish', - 'Redfish', - 'Bocaccio', - 'Thornyhead', - 'Gurnard', - 'Searobin', - 'Fangtooth', - 'Bristlemouth', - 'Barreleye', - 'Spookfish', - 'Telescopefish', - 'Lancetfish', - 'Tripodfish', - 'Spiderfish', - 'Daggertooth', - 'Pearleye', - 'Ridgehead', - 'Snaggletooth', - 'Sturgeon', - 'Sterlet', - 'Kaluga', - 'Cockle', - 'Mussel', - 'Clam', - 'Oyster', - 'Scallop', - 'Abalone', - 'Periwinkle', - 'Cerith', - 'Turban', - 'Tellin', - 'Geoduck', - 'Quahog', - 'Piddock', - 'Shipworm', - 'Lobster', - 'Crayfish', - 'Shrimp', - 'Prawn', - 'Langostino', - 'Langouste', - 'Yabby', - 'Marron', - 'Dungeness', - 'Seafan', - 'Staghorn', - 'Elkhorn', - 'Hydroid', - 'Zoanthid', - 'Gorgonian', - 'Seanettle', - 'Moonjelly', - 'Sanddollar', - 'Brittlestar', - 'Basketstar', - 'Sunstar', - 'Seacucumber', - 'Seabiscuit', - 'Hearturchin', - 'Bristleworm', - 'Featherduster', - 'Lugworm', - 'Sandworm', - 'Palolo', - 'Arrowworm', - 'Acornworm', - 'Flatworm', - 'Ribbonworm', - 'Booby', - 'Frigatebird', - 'Tropicbird', - 'Noddy', - 'Kittiwake', - 'Dovekie', - 'Shag', - 'Anhinga', - 'Skimmer', - 'Jaeger', - 'Oystercatcher', - 'Eider', - 'Scoter', - 'Merganser', - 'Brant', - 'Ridley', - 'Flatback', - 'Terrapin', - 'Seasnake', - 'Diatom', - 'Plankton', - 'Larvacean', - 'Doliolid', - 'Kelp', - 'Seagrass', - 'Eelgrass', - 'Dulse', - 'Bladderwrack', - 'Mola', - // Mythological sea & water creatures from public-domain folklore — joining - // Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above. - 'Scylla', - 'Charybdis', - 'Cetus', - 'Proteus', - 'Glaucus', - 'Hippocamp', - 'Jormungandr', - 'Hafgufa', - 'Kelpie', - 'Merrow', - 'Nuckelavee', - 'Afanc', - 'Rusalka', - 'Vodyanoy', - 'Umibozu', - 'Isonade', - 'Ningyo', - 'Mizuchi', - 'Naga', - 'Makara', - 'Bunyip', - 'Taniwha', - 'Marakihau', - 'Lusca', - 'Undine', - 'Nixie', - 'Melusine', - 'Ondine', - 'Tiamat', - 'Dagon', - 'Aspidochelone', - 'Capricorn', - 'Merfolk', - 'Merman', - 'Mermaid', - 'Timingila', - 'Bakekujira', - 'Jiaolong', - 'Encantado', - 'Hraesvelg' -] as const diff --git a/mobile/src/expo-route-module-boundary.test.ts b/mobile/src/expo-route-module-boundary.test.ts new file mode 100644 index 00000000000..3870f32c216 --- /dev/null +++ b/mobile/src/expo-route-module-boundary.test.ts @@ -0,0 +1,121 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { basename, dirname, extname, join, relative } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const appDirectory = fileURLToPath(new URL('../app', import.meta.url)) +const routeSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']) + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? sourceFiles(path) : [path] + }) +} + +function isNonScreenExpoModule(path: string): boolean { + const fileName = basename(path) + if (/\+api\.[jt]sx?$/.test(fileName)) { + return true + } + return ( + dirname(relative(appDirectory, path)) === '.' && + /^\+(?:html|middleware|native-intent)\.[jt]sx?$/.test(fileName) + ) +} + +function isPlatformSpecificApiRoute(path: string): boolean { + return /\+api\.(?:android|ios|native|web)\.[jt]sx?$/.test(basename(path)) +} + +function hasDefaultExport(path: string, source: string): boolean { + const extension = extname(path) + const sourceFile = ts.createSourceFile( + path, + source, + ts.ScriptTarget.Latest, + true, + extension === '.jsx' + ? ts.ScriptKind.JSX + : extension === '.js' + ? ts.ScriptKind.JS + : extension === '.tsx' + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS + ) + + return sourceFile.statements.some((statement) => { + if (ts.isExportAssignment(statement)) { + return !statement.isExportEquals + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.exportClause) { + if (ts.isNamespaceExport(statement.exportClause)) { + return statement.exportClause.name.text === 'default' + } + return statement.exportClause.elements.some( + (element) => !element.isTypeOnly && element.name.text === 'default' + ) + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + return ( + !ts.isInterfaceDeclaration(statement) && + !ts.isTypeAliasDeclaration(statement) && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) !== true && + modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) === true && + modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) + }) +} + +function isInvalidRouteModule(path: string, source: string): boolean { + return ( + isPlatformSpecificApiRoute(path) || + (!isNonScreenExpoModule(path) && !hasDefaultExport(path, source)) + ) +} + +describe('Expo route module boundary', () => { + it('allows Expo modules that are not screen routes', () => { + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'health+api.ios.ts'))).toBe(false) + expect(isNonScreenExpoModule(join(appDirectory, '+html.tsx'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+middleware.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, '+native-intent.ts'))).toBe(true) + expect(isNonScreenExpoModule(join(appDirectory, 'nested', '+middleware.ts'))).toBe(false) + }) + + it('recognizes syntax-level default exports', () => { + expect(hasDefaultExport('route.tsx', 'export default function Route() {}')).toBe(true) + expect( + hasDefaultExport('route.jsx', 'export default function Route() { return }') + ).toBe(true) + expect(hasDefaultExport('route.ts', "export { default } from './route-screen'")).toBe(true) + expect(hasDefaultExport('route.ts', "export { Route as default } from './route-screen'")).toBe( + true + ) + expect(hasDefaultExport('support.ts', '// export default')).toBe(false) + expect(hasDefaultExport('support.ts', "const marker = 'export default'")).toBe(false) + expect(hasDefaultExport('support.ts', 'export default interface Support {}')).toBe(false) + expect( + hasDefaultExport('support.ts', "export type { Support as default } from './types'") + ).toBe(false) + }) + + it('rejects platform-specific API routes even with a default export', () => { + expect(isPlatformSpecificApiRoute(join(appDirectory, 'health+api.ts'))).toBe(false) + for (const platform of ['android', 'ios', 'native', 'web']) { + const path = join(appDirectory, `health+api.${platform}.ts`) + expect(isInvalidRouteModule(path, 'export default function Route() {}')).toBe(true) + } + }) + + it('keeps support modules outside the app route directory', () => { + const invalidRoutes = sourceFiles(appDirectory) + .filter((path) => routeSourceExtensions.has(extname(path))) + .filter((path) => isInvalidRouteModule(path, readFileSync(path, 'utf8'))) + .map((path) => relative(appDirectory, path)) + + expect(invalidRoutes).toEqual([]) + }) +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx index 89a2df5f76a..387d650712e 100644 --- a/mobile/src/session/MobileBrowserTabActionSheet.tsx +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -1,5 +1,5 @@ import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' import { ActionSheetModal, type ActionSheetAction } from '../components/ActionSheetModal' import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' import { t } from '@/i18n/mobile-i18n' diff --git a/mobile/src/session/MobileSessionHeaderIconButton.tsx b/mobile/src/session/MobileSessionHeaderIconButton.tsx index 68e1a1173a4..b1037bb958f 100644 --- a/mobile/src/session/MobileSessionHeaderIconButton.tsx +++ b/mobile/src/session/MobileSessionHeaderIconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from 'react' import { Pressable } from 'react-native' import { colors } from '../theme/mobile-theme' -import { styles } from '../../app/h/[hostId]/session/mobile-session-styles' +import { styles } from './mobile-session-styles' type HeaderIconProps = { size?: number diff --git a/mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx b/mobile/src/session/QuickCommandsTabButton.tsx similarity index 93% rename from mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx rename to mobile/src/session/QuickCommandsTabButton.tsx index 64fd42cf29c..5edc553fc5c 100644 --- a/mobile/app/h/[hostId]/session/QuickCommandsTabButton.tsx +++ b/mobile/src/session/QuickCommandsTabButton.tsx @@ -1,7 +1,7 @@ import { Pressable, View } from 'react-native' import { SquareChevronRight } from 'lucide-react-native' -import { colors } from '../../../../src/theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' import { t } from '@/i18n/mobile-i18n' diff --git a/mobile/src/session/github-pr-rpc.ts b/mobile/src/session/github-pr-rpc.ts index 6f59579e058..ad8284811c7 100644 --- a/mobile/src/session/github-pr-rpc.ts +++ b/mobile/src/session/github-pr-rpc.ts @@ -149,7 +149,10 @@ export async function fetchHostedReviewForBranch( { repo: mobileRepoSelectorFromWorktreeId(worktreeId), branch: args.branch, - linkedGitHubPR: args.linkedGitHubPR ?? null + linkedGitHubPR: args.linkedGitHubPR ?? null, + // Why: the mobile PR sidebar is only ever open on the selected worktree, + // so it belongs in the host's fast re-check tier (#11532). + active: true }, readForBranch ) diff --git a/mobile/src/session/mobile-bulk-close-sheet-actions.ts b/mobile/src/session/mobile-bulk-close-sheet-actions.ts index 2729037f3da..06758b3f774 100644 --- a/mobile/src/session/mobile-bulk-close-sheet-actions.ts +++ b/mobile/src/session/mobile-bulk-close-sheet-actions.ts @@ -1,7 +1,4 @@ -import type { - MarkdownDocState, - MobileSessionTab -} from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MarkdownDocState, MobileSessionTab } from './mobile-session-route-types' import type { ActionSheetAction } from '../components/ActionSheetModal' import { BULK_TAB_CLOSE_ACTIONS, diff --git a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts b/mobile/src/session/mobile-session-command-input-styles.ts similarity index 98% rename from mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts rename to mobile/src/session/mobile-session-command-input-styles.ts index 1bb852c4218..b19c13a502e 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts +++ b/mobile/src/session/mobile-session-command-input-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionCommandInputStyles = StyleSheet.create({ createWarningBanner: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts rename to mobile/src/session/mobile-session-frame-styles.ts index 738632ba495..a02c14be014 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionFrameStyles = StyleSheet.create({ container: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts b/mobile/src/session/mobile-session-reader-styles.ts similarity index 97% rename from mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts rename to mobile/src/session/mobile-session-reader-styles.ts index aba5340ec7a..eedd47d328b 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-reader-styles.ts +++ b/mobile/src/session/mobile-session-reader-styles.ts @@ -1,6 +1,6 @@ import { Platform, StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionReaderStyles = StyleSheet.create({ markdownTextInput: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts b/mobile/src/session/mobile-session-review-comment-styles.ts similarity index 98% rename from mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts rename to mobile/src/session/mobile-session-review-comment-styles.ts index b9d19578b83..8d8b2d11ccd 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-review-comment-styles.ts +++ b/mobile/src/session/mobile-session-review-comment-styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native' -import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' export const mobileSessionReviewCommentStyles = StyleSheet.create({ diffCommentAddButton: { diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts similarity index 87% rename from mobile/app/h/[hostId]/session/mobile-session-route-types.ts rename to mobile/src/session/mobile-session-route-types.ts index 57f4365e80c..5b72a397349 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -1,13 +1,10 @@ -import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' -import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract' -import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines' -import type { - MobileHighlightedDiffLine, - MobileSyntaxSegment -} from '../../../../src/session/mobile-file-syntax' -import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records' -import type { DiffComment, TuiAgent } from '../../../../../src/shared/types' -import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types' +import type { DiffComment, TuiAgent } from '../../../src/shared/types' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { MobileBrowserTab } from '../browser/MobileBrowserPane' +import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract' +import type { MobileDiffLine } from './mobile-diff-lines' +import type { MobileHighlightedDiffLine, MobileSyntaxSegment } from './mobile-file-syntax' +import type { TerminalRecord } from './mobile-terminal-records' export type Terminal = TerminalRecord diff --git a/mobile/app/h/[hostId]/session/mobile-session-styles.ts b/mobile/src/session/mobile-session-styles.ts similarity index 100% rename from mobile/app/h/[hostId]/session/mobile-session-styles.ts rename to mobile/src/session/mobile-session-styles.ts diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index fcc7452e989..8449f988e24 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { TuiAgent } from '../../../src/shared/types' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' import { getMobileSessionTabTitle, resolveMobileTerminalTabAgentId diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index 1b44ad0ffd8..b645e16c790 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -3,7 +3,7 @@ import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/termi import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { TuiAgent } from '../../../src/shared/types' import { isBlankBrowserUrl } from '../browser/browser-url' -import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { MobileSessionTab } from './mobile-session-route-types' import { t } from '@/i18n/mobile-i18n' // Why: tab identity + title cleaning uses the same shared glyph/label maps as diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index c7e7f9a09c4..2e51c25067d 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -10,7 +10,7 @@ const liveInputStatusSource = readFileSync( 'utf8' ) const commandInputStylesSource = readFileSync( - new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), + new URL('../session/mobile-session-command-input-styles.ts', import.meta.url), 'utf8' ) const liveInputFocusSource = readFileSync( diff --git a/mobile/src/theme/mobile-theme-contrast.test.ts b/mobile/src/theme/mobile-theme-contrast.test.ts new file mode 100644 index 00000000000..6546e6aaa43 --- /dev/null +++ b/mobile/src/theme/mobile-theme-contrast.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { colors } from './mobile-theme' + +function channelLuminance(channel: number): number { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 +} + +function relativeLuminance(hex: string): number { + const red = Number.parseInt(hex.slice(1, 3), 16) + const green = Number.parseInt(hex.slice(3, 5), 16) + const blue = Number.parseInt(hex.slice(5, 7), 16) + return ( + 0.2126 * channelLuminance(red) + + 0.7152 * channelLuminance(green) + + 0.0722 * channelLuminance(blue) + ) +} + +function contrastRatio(foreground: string, background: string): number { + const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background)) + const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background)) + return (lighter + 0.05) / (darker + 0.05) +} + +describe('mobile text contrast', () => { + it('keeps muted text readable on every standard dark surface', () => { + for (const surface of [colors.bgBase, colors.bgPanel, colors.bgRaised]) { + expect(contrastRatio(colors.textMuted, surface)).toBeGreaterThanOrEqual(4.5) + } + }) + + it('keeps secondary text more prominent than muted text', () => { + expect(contrastRatio(colors.textSecondary, colors.bgPanel)).toBeGreaterThan( + contrastRatio(colors.textMuted, colors.bgPanel) + ) + }) +}) diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index dbf09b745b9..0ee1771ecb7 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -9,8 +9,8 @@ export const colors = { editorSurface: '#1e1e1e', textPrimary: '#e0e0e0', - textSecondary: '#888888', - textMuted: '#555555', + textSecondary: '#a1a1a1', + textMuted: '#8c8c8c', // Crisp near-white surface for the single primary action on a screen (the // worktree FAB). Brighter than textPrimary so it reads as a solid button, not diff --git a/package.json b/package.json index fd23075a038..81e8b1a05e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.163", + "version": "1.4.165-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -16,6 +16,7 @@ "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", "audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none", + "audit:dead-code": "pnpm dlx knip@5 --config config/knip.json", "check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs", "check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs", "check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check", @@ -219,6 +220,7 @@ "react-dom": "^19.2.7", "react-grab": "^0.1.33", "react-markdown": "^10.1.0", + "react-refresh": "^0.18.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 491c536773b..dbe19cf1e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-refresh: + specifier: ^0.18.0 + version: 0.18.0 rehype-highlight: specifier: ^7.0.2 version: 7.0.2 diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index d797cfbab1b..0b7ccdb7d6b 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -527,7 +527,7 @@ export const ORCHESTRATION_HANDLERS: Record = { throwNoActiveSenderTerminal() } - // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle). + // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') const sendParams = { from, @@ -1123,7 +1123,7 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') - // Why: same read posture as task-list — an explicitly named Run stays inspectable without a bound pane, so identity is only resolved for the implicit "my own Run" case. + // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] diff --git a/src/cli/runtime/environments.ts b/src/cli/runtime/environments.ts index 3009b11c9cb..c3594254587 100644 --- a/src/cli/runtime/environments.ts +++ b/src/cli/runtime/environments.ts @@ -20,10 +20,6 @@ export type EnvironmentAddResult = { environment: PublicKnownRuntimeEnvironment } -export type EnvironmentListResult = { - environments: PublicKnownRuntimeEnvironment[] -} - export type EnvironmentRemoveResult = { removed: PublicKnownRuntimeEnvironment } diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 2b53c8fbc0a..3729b8598a7 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -211,7 +211,7 @@ export async function getComputerCommandTarget( } } -// Mirror getBrowserCommandTarget / getBrowserWorktreeSelector for emulator (workspace scoped by default + explicit --device/--emulator/--worktree; active from bridge for unqualified). +// Match browser targeting: workspace by default, explicit device/emulator/worktree overrides. export type EmulatorCliTarget = { worktree?: string device?: string diff --git a/src/main/active-view-preference-sync-flush-veto.test.ts b/src/main/active-view-preference-sync-flush-veto.test.ts new file mode 100644 index 00000000000..343f6fb9f2f --- /dev/null +++ b/src/main/active-view-preference-sync-flush-veto.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import type * as Fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why this file is separate: the swap in writeAsync is async, so a writer parked on its +// rename has already cleared the generation guard and nothing downstream can veto it. +// flushOrThrow runs synchronously from session checkpoints and renderer shutdown, so a +// stale view landing on top of it is a real loss. Gating rename is the only way to pin it. + +const gate = vi.hoisted(() => ({ + blockRename: false, + blockAfterRename: false, + waiters: [] as (() => void)[], + afterRenameWaiters: [] as (() => void)[], + renameCompleted: [] as (() => void)[], + renameCalls: 0, + failUnlink: false +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + const unlinkSync = ((...args: Parameters) => { + if (gate.failUnlink) { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + } + return actual.unlinkSync(...args) + }) as typeof actual.unlinkSync + return { ...actual, unlinkSync } +}) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + const rename = (async (...args: Parameters) => { + gate.renameCalls += 1 + if (gate.blockRename) { + await new Promise((resolve) => gate.waiters.push(resolve)) + } + const result = await actual.rename(...args) + gate.renameCompleted.splice(0).forEach((resolve) => resolve()) + if (gate.blockAfterRename) { + await new Promise((resolve) => gate.afterRenameWaiters.push(resolve)) + } + return result + }) as typeof actual.rename + return { ...actual, rename } +}) + +describe('ActiveViewPreference sync flush vs. parked async rename', () => { + let dir: string + const viewFile = (): string => join(dir, 'active-view.json') + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-active-view-')) + gate.blockRename = false + gate.blockAfterRename = false + gate.waiters = [] + gate.afterRenameWaiters = [] + gate.renameCompleted = [] + gate.renameCalls = 0 + gate.failUnlink = false + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('a write parked on its rename cannot overwrite a later flushOrThrow', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + + gate.blockRename = true + pref.set('activity') + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + // Why grab it now: flushOrThrow drops pendingWrite, so waitForPendingWrite would + // return before the released rename has actually landed. + const inflight = (pref as unknown as { pendingWrite: Promise | null }).pendingWrite + + // A session checkpoint fires while that write is still parked. + pref.set('tasks') + pref.flushOrThrow() + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await inflight + + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + }) + + it('an unlink veto failure queues a newest-value correction', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + + gate.blockRename = true + pref.set('activity') + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + + pref.set('tasks') + gate.failUnlink = true + expect(() => pref.flushOrThrow()).toThrow('busy') + + gate.failUnlink = false + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await pref.waitForPendingWrite() + + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + }) + + it('a completed stale rename cannot regress the persisted marker', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + const renameCompleted = new Promise((resolve) => gate.renameCompleted.push(resolve)) + + gate.blockAfterRename = true + pref.set('activity') + await renameCompleted + + pref.set('tasks') + pref.flushOrThrow() + gate.blockAfterRename = false + gate.afterRenameWaiters.splice(0).forEach((resolve) => resolve()) + await pref.waitForPendingWrite() + + pref.set('activity') + await vi.waitFor(() => { + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('activity') + }) + await pref.waitForPendingWrite() + }) + + it('a pending flush drains a view changed during its rename', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + + gate.blockRename = true + pref.set('activity') + const flush = pref.flushPendingAsync() + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + + pref.set('tasks') + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await flush + + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + expect(gate.renameCalls).toBe(2) + }) + + it('coalesces concurrent pending flushes while draining the newest view', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + + gate.blockRename = true + pref.set('activity') + const firstFlush = pref.flushPendingAsync() + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + + pref.set('tasks') + const secondFlush = pref.flushPendingAsync() + expect(secondFlush).toBe(firstFlush) + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await Promise.all([firstFlush, secondFlush]) + + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + expect(gate.renameCalls).toBe(2) + }) + + it('keeps an unabortable shared drain alive when an attached signal aborts', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + const controller = new AbortController() + + gate.blockRename = true + pref.set('activity') + const flush = pref.flushPendingAsync() + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + expect(pref.flushPendingAsync(controller.signal)).toBe(flush) + + pref.set('tasks') + controller.abort() + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await expect(flush).resolves.toBeUndefined() + expect(JSON.parse(readFileSync(viewFile(), 'utf-8')).activeView).toBe('tasks') + expect(gate.renameCalls).toBe(2) + }) + + it('stops an abortable-only drain after its signal aborts', async () => { + const { ActiveViewPreference } = await import('./active-view-preference') + const pref = new ActiveViewPreference(join(dir, 'orca-data.json'), 'terminal') + const controller = new AbortController() + + gate.blockRename = true + pref.set('activity') + const flush = pref.flushPendingAsync(controller.signal) + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + + controller.abort() + gate.blockRename = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await expect(flush).rejects.toThrow('Active-view flush aborted') + expect(gate.renameCalls).toBe(1) + }) +}) diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index beed983aef4..5bd78b14713 100644 --- a/src/main/active-view-preference.ts +++ b/src/main/active-view-preference.ts @@ -1,5 +1,5 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import type { TopLevelView } from '../shared/types' import { isTopLevelView } from '../shared/top-level-view' @@ -34,7 +34,15 @@ export class ActiveViewPreference { private persistedActiveView: TopLevelView | null private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null + private pendingFlush: Promise | null = null + private flushSignals = new Set() + private unabortableFlushConsumers = 0 private writeGeneration = 0 + /** Temp path of the write awaiting its rename; flushOrThrow removes it to veto that rename. */ + private inFlightTmpFile: string | null = null + /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ + private quitFlushStarted = false + private quitFlushPromise: Promise | null = null constructor(dataFile: string, legacyActiveView: unknown) { this.file = getActiveViewPreferenceFile(dataFile) @@ -65,44 +73,68 @@ export class ActiveViewPreference { } private scheduleSave(): void { + // Why: the quit flush is the final write; a later debounce would land unawaited mid-exit. + if (this.quitFlushStarted) { + return + } this.writeGeneration += 1 if (this.writeTimer) { clearTimeout(this.writeTimer) } this.writeTimer = setTimeout(() => { this.writeTimer = null - const generation = this.writeGeneration - const activeView = this.activeView - const previousWrite = this.pendingWrite ?? Promise.resolve() - const nextWrite = previousWrite - .then(() => this.writeAsync(activeView, generation)) - .catch((error) => { - console.error('[active-view] Failed to persist preference:', error) - }) - .finally(() => { - if (this.pendingWrite === nextWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = nextWrite + void this.enqueueWrite(this.activeView, this.writeGeneration) }, SAVE_DEBOUNCE_MS) } + private enqueueWrite(activeView: TopLevelView, generation: number): Promise { + const previousWrite = this.pendingWrite ?? Promise.resolve() + const nextWrite = previousWrite + .then(() => this.writeAsync(activeView, generation)) + .catch((error) => { + console.error('[active-view] Failed to persist preference:', error) + }) + .finally(() => { + if (this.pendingWrite === nextWrite) { + this.pendingWrite = null + } + }) + this.pendingWrite = nextWrite + return nextWrite + } + private async writeAsync(activeView: TopLevelView, generation: number): Promise { const tmpFile = `${this.file}.${process.pid}.${generation}.tmp` let renamed = false try { await mkdir(dirname(this.file), { recursive: true }) await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8') - // Why: keep the generation guard and the swap synchronous (no await between), - // or a concurrent flushOrThrow could rename a newer view that this stale write - // then clobbers, restoring the prior view on next launch. + // Every async writer chains on pendingWrite, so a stale generation can only lose + // here. Sync flushOrThrow skips that chain, which is what the temp-path claim below + // covers — past this point the generation guard is spent. if (generation !== this.writeGeneration) { return } - renameSync(tmpFile, this.file) - renamed = true - this.persistedActiveView = activeView + this.inFlightTmpFile = tmpFile + try { + await rename(tmpFile, this.file) + renamed = true + if (generation === this.writeGeneration) { + this.persistedActiveView = activeView + } + } catch (err) { + // ENOENT: flushOrThrow removed this temp file because it already wrote a fresher view. + if ( + (err as NodeJS.ErrnoException).code !== 'ENOENT' || + generation === this.writeGeneration + ) { + throw err + } + } finally { + if (this.inFlightTmpFile === tmpFile) { + this.inFlightTmpFile = null + } + } } finally { if (!renamed) { await rm(tmpFile).catch(() => {}) @@ -111,16 +143,32 @@ export class ActiveViewPreference { } flushOrThrow(): void { + if (this.quitFlushStarted) { + throw new Error('Cannot synchronously flush active view after final persistence has started') + } if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null } const asyncWriteWasInFlight = this.pendingWrite !== null this.writeGeneration += 1 - this.pendingWrite = null if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) { return } + // Why remove it: an async writer parked on `await rename` has already cleared the + // generation guard, so deleting what it would rename is the only thing stopping a + // stale view from landing on top of the write below. + if (this.inFlightTmpFile) { + try { + unlinkSync(this.inFlightTmpFile) + this.inFlightTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void this.enqueueWrite(this.activeView, this.writeGeneration) + throw error + } + } + } mkdirSync(dirname(this.file), { recursive: true }) const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp` writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8') @@ -128,6 +176,71 @@ export class ActiveViewPreference { this.persistedActiveView = this.activeView } + /** Async twin of flushOrThrow() for the quit path; never throws so it can join the teardown barrier. */ + flushAsync(): Promise { + if (this.quitFlushPromise) { + return this.quitFlushPromise + } + this.quitFlushStarted = true + this.quitFlushPromise = this.flushPendingAsync() + return this.quitFlushPromise + } + + flushPendingAsync(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(new Error('Active-view flush aborted')) + } + if (signal) { + this.flushSignals.add(signal) + } else { + this.unabortableFlushConsumers += 1 + } + if (this.pendingFlush) { + return this.pendingFlush + } + const run = this.drainPendingWrites() + const tracked = run.finally(() => { + if (this.pendingFlush === tracked) { + this.pendingFlush = null + this.flushSignals.clear() + this.unabortableFlushConsumers = 0 + } + }) + this.pendingFlush = tracked + return tracked + } + + private async drainPendingWrites(): Promise { + for (;;) { + if (this.allFlushConsumersAborted()) { + throw new Error('Active-view flush aborted') + } + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + if (this.pendingWrite === null && this.activeView === this.persistedActiveView) { + return + } + const generation = ++this.writeGeneration + await this.enqueueWrite(this.activeView, generation) + if (this.allFlushConsumersAborted()) { + throw new Error('Active-view flush aborted') + } + if (generation === this.writeGeneration) { + return + } + } + } + + private allFlushConsumersAborted(): boolean { + return ( + this.unabortableFlushConsumers === 0 && + this.flushSignals.size > 0 && + [...this.flushSignals].every((signal) => signal.aborted) + ) + } + async waitForPendingWrite(): Promise { if (this.pendingWrite) { await this.pendingWrite diff --git a/src/main/agent-auth-restart-preservation.test.ts b/src/main/agent-auth-restart-preservation.test.ts index 057e3d876f7..441ec609624 100644 --- a/src/main/agent-auth-restart-preservation.test.ts +++ b/src/main/agent-auth-restart-preservation.test.ts @@ -24,7 +24,7 @@ describe('preserveAgentAuthBeforeRestart', () => { }) }, store: { - flush: vi.fn(() => { + flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') }) } @@ -43,7 +43,7 @@ describe('preserveAgentAuthBeforeRestart', () => { syncActiveWslSelectionsBeforeRestart }, store: { - flush: vi.fn() + flushPendingOrThrowAsync: vi.fn() } }) @@ -70,7 +70,7 @@ describe('preserveAgentAuthBeforeRestart', () => { }) }, store: { - flush: vi.fn(() => { + flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') }) } @@ -81,7 +81,7 @@ describe('preserveAgentAuthBeforeRestart', () => { it('continues after WSL Codex preservation fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const flush = vi.fn() + const flushPendingOrThrowAsync = vi.fn() await preserveAgentAuthBeforeRestart({ codexRuntimeHome: { @@ -91,25 +91,25 @@ describe('preserveAgentAuthBeforeRestart', () => { }) }, store: { - flush + flushPendingOrThrowAsync } }) - expect(flush).toHaveBeenCalledTimes(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) it('flushes the store when auth services are missing', async () => { - const flush = vi.fn() + const flushPendingOrThrowAsync = vi.fn() - await preserveAgentAuthBeforeRestart({ store: { flush } }) + await preserveAgentAuthBeforeRestart({ store: { flushPendingOrThrowAsync } }) - expect(flush).toHaveBeenCalledTimes(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) }) it('logs secret-free warnings and does not throw when sync fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const flush = vi.fn() + const flushPendingOrThrowAsync = vi.fn() await expect( preserveAgentAuthBeforeRestart({ @@ -124,11 +124,11 @@ describe('preserveAgentAuthBeforeRestart', () => { throw new Error('claude-token-secret') }) }, - store: { flush } + store: { flushPendingOrThrowAsync } }) ).resolves.toBeUndefined() - expect(flush).toHaveBeenCalledTimes(1) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledTimes(2) expect(JSON.stringify(warn.mock.calls)).not.toContain('token-secret') }) @@ -150,7 +150,7 @@ describe('preserveAgentAuthBeforeRestart', () => { }) }, store: { - flush: vi.fn(() => { + flushPendingOrThrowAsync: vi.fn(async () => { calls.push('flush') }) } @@ -169,4 +169,19 @@ describe('preserveAgentAuthBeforeRestart', () => { expect(calls).toEqual(['claude-start', 'flush', 'claude-finish']) }) + + it('bounds a store flush that never settles', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const preservation = preserveAgentAuthBeforeRestart({ + store: { flushPendingOrThrowAsync: vi.fn(() => new Promise(() => {})) } + }) + + await vi.advanceTimersByTimeAsync(2_000) + await preservation + + expect(warn).toHaveBeenCalledWith( + '[agent-auth-restart] Store persistence exceeded 2000ms; continuing restart/update' + ) + }) }) diff --git a/src/main/agent-auth-restart-preservation.ts b/src/main/agent-auth-restart-preservation.ts index cb3fe94921a..eaae45f331b 100644 --- a/src/main/agent-auth-restart-preservation.ts +++ b/src/main/agent-auth-restart-preservation.ts @@ -9,9 +9,12 @@ type CodexRuntimeAuthSync = Pick< 'syncForCurrentSelection' | 'syncActiveWslSelectionsBeforeRestart' > type ClaudeRuntimeAuthSync = Pick -type ShutdownStore = Pick +type ShutdownStore = Pick -type AuthPreservationStep = 'Codex auth preservation' | 'Claude auth preservation' +type AuthPreservationStep = + | 'Codex auth preservation' + | 'Claude auth preservation' + | 'Store persistence' export type AgentAuthRestartPreservationOptions = { codexRuntimeHome?: CodexRuntimeAuthSync | null @@ -45,10 +48,13 @@ export async function preserveAgentAuthBeforeRestart({ logStepTimeout('Codex auth preservation', 0) } - try { - store?.flush() - } catch (error) { - logStoreFlushFailure(error) + if (store) { + const storeRemainingMs = Math.max(0, AUTH_PRESERVATION_TIMEOUT_MS - (Date.now() - startedAt)) + await runWithinLifecycleTimeout( + 'Store persistence', + () => store.flushPendingOrThrowAsync(), + storeRemainingMs + ) } } @@ -109,12 +115,6 @@ function logStepTimeout(step: AuthPreservationStep, timeoutMs: number): void { console.warn(`[agent-auth-restart] ${step} exceeded ${timeoutMs}ms; continuing restart/update`) } -function logStoreFlushFailure(error: unknown): void { - console.warn( - `[agent-auth-restart] Store flush failed (${describeErrorKind(error)}); continuing restart/update` - ) -} - function describeErrorKind(error: unknown): string { if (error instanceof Error) { return error.name || 'Error' diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts index cc450a3c36d..8efe3fd7633 100644 --- a/src/main/agent-hooks/server.claude-interactive-question.test.ts +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -11,6 +11,7 @@ function ingestClaudeStatus( hookEventName: 'PermissionRequest' | 'PreToolUse' toolName: string toolUseId: string + interactivePrompt?: string } ): void { server.ingestRemote( @@ -23,7 +24,8 @@ function ingestClaudeStatus( payload: { state: event.state, agentType: 'claude', - toolName: event.toolName + toolName: event.toolName, + ...(event.interactivePrompt ? { interactivePrompt: event.interactivePrompt } : {}) } }, 'connection-1' @@ -127,6 +129,72 @@ function answeredRequestFromSnapshot( } } +function escapeRequestFromSnapshot( + server: AgentHookServer +): Parameters[0] { + return { + ...answeredRequestFromSnapshot(server), + intent: 'plain-escape' + } +} + +describe('inferInterrupt for Claude interactive questions', () => { + it.each(['PreToolUse', 'PermissionRequest'] as const)( + 'clears a %s AskUserQuestion wait after Escape', + (hookEventName) => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName, + toolName: 'AskUserQuestion', + toolUseId: 'tool-question', + interactivePrompt: '{"questions":[{"question":"Pick one"}]}' + }) + + expect(server.inferInterrupt(escapeRequestFromSnapshot(server))).toBe(true) + const [entry] = server.getStatusSnapshot() + expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' }) + expect(entry.toolName).toBeUndefined() + expect(entry.interactivePrompt).toBeUndefined() + expect(entry.interrupted).toBeUndefined() + } + ) + + it('rejects Escape when the question baseline is stale', () => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + const request = { ...escapeRequestFromSnapshot(server), baselineUpdatedAt: 1 } + + expect(server.inferInterrupt(request)).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName: 'AskUserQuestion' }) + ]) + }) + + it.each([ + ['plain-escape', 'Bash'], + ['ctrl-c', 'AskUserQuestion'] + ] as const)('does not clear a Claude wait from %s on %s', (intent, toolName) => { + const server = new AgentHookServer() + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName, + toolUseId: 'tool-wait' + }) + + expect(server.inferInterrupt({ ...escapeRequestFromSnapshot(server), intent })).toBe(false) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', toolName }) + ]) + }) +}) + describe('inferQuestionAnswered', () => { it('clears an AskUserQuestion wait when the submit keystroke is reported', () => { const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index c8baaa6e69c..b3ff89ab355 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -31,6 +31,7 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import { makePaneKey } from '../../shared/stable-pane-id' +import { createShedSubagentsField } from '../../shared/agent-hook-relay' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -55,6 +56,7 @@ const GOOD_PANE = makePaneKey('tab-good', LEAF_2) const OLD_PANE = makePaneKey('tab-old', LEAF_3) const FRESH_PANE = makePaneKey('tab-fresh', LEAF_4) const TAB_A_PANE = makePaneKey('tab-A', LEAF_5) +const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } type Body = { paneKey: string @@ -187,6 +189,91 @@ describe('AgentHookServer listener replay', () => { expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')]) }) + it('restores a subagent roster the relay shed to fit the frame', () => { + const server = new AgentHookServer() + const roster = [ + { id: 'reviewer-1', agentType: 'reviewer', state: 'working' as const, startedAt: 1 } + ] + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'review', agentType: 'claude', subagents: roster } + }, + 'conn-1' + ) + // The relay dropped the roster to fit an oversized frame and said so on the wire. + server.ingestRemote( + { + paneKey: PANE, + shedFields: ['lastAssistantMessage', createShedSubagentsField(roster)], + payload: { state: 'done', prompt: 'review', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', subagents: roster }) + }) + + it('lets an unmarked absent roster clear, so a finished team still retires', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + payload: { + state: 'working', + prompt: 'review', + agentType: 'claude', + subagents: [{ id: 'reviewer-1', agentType: 'reviewer', state: 'working', startedAt: 1 }] + } + }, + 'conn-1' + ) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'done', prompt: 'review', agentType: 'claude' } }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }) + + it('does not carry Claude background work across connection cleanup', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'old host', agentType: 'claude' } + }, + 'conn-1' + ) + + server.clearStatusEntriesForConnection('conn-1') + server.ingestRemote( + { + paneKey: PANE, + payload: { state: 'working', prompt: 'new host', agentType: 'claude' } + }, + 'conn-2' + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'new host', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it('retains root Codex identity when relay child events omit it', () => { const server = new AgentHookServer() const providerSession = { key: 'session_id' as const, id: 'root-session' } @@ -351,6 +438,232 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not infer an interrupt while Claude reports a background shell', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } + }, + 'conn-1' + ) + let baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(false) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: false, + payload: { state: 'working', prompt: 'run in background', agentType: 'claude' } + }, + 'conn-1' + ) + baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(2_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('blocks local HTTP interrupt inference for provider-owned work without exposing transport metadata', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + + await expect( + postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + postHook({ hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + ).resolves.toMatchObject({ status: 204 }) + const baseline = server.getStatusSnapshot()[0] + + expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'plain-escape' + }) + ).toBe(false) + + await expect( + postHook({ + hook_event_name: 'Stop', + background_tasks: [], + session_crons: [{ id: 'cron-1' }] + }) + ).resolves.toMatchObject({ status: 204 }) + const cronBaseline = server.getStatusSnapshot()[0] + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: cronBaseline.receivedAt, + baselineStateStartedAt: cronBaseline.stateStartedAt, + baselinePrompt: 'run in background', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(false) + } finally { + server.stop() + } + }) + + it('uses replayed Claude background metadata only before a live observation', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + isReplay: true, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'stale replay', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(false) + + vi.setSystemTime(1_500) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + claudeRunningNonAgentTask: false, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + vi.setSystemTime(2_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + isReplay: true, + claudeRunningNonAgentTask: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + const liveBaseline = server.getStatusSnapshot()[0] + + vi.setSystemTime(2_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: liveBaseline.receivedAt, + baselineStateStartedAt: liveBaseline.stateStartedAt, + baselinePrompt: 'stale replay', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('does not apply Claude background metadata from a rejected remote status', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'PermissionRequest', + claudeRunningNonAgentTask: true, + payload: { + state: 'waiting', + prompt: 'approve shell', + agentType: 'claude', + toolName: 'Bash' + } + }, + 'conn-1' + ) + const waiting = server.getStatusSnapshot()[0] + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'PreToolUse', + claudeRunningNonAgentTask: false, + payload: { + state: 'working', + prompt: 'approve shell', + agentType: 'claude', + toolName: 'OtherTool' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toEqual(waiting) + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) + }) + it('carries idle subagent rows through an inferred interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -1906,6 +2219,97 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a new local prompt after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + await postHook({ hook_event_name: 'Stop', last_assistant_message: 'late prior hook' }) + expect(server.getStatusSnapshot()).toEqual([]) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'manual restart' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'manual restart' }) + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('retired-launch-token').digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + ).toBeNull() + } finally { + server.stop() + } + }) + + it('accepts a live remote prompt but not replay after launch authority retires', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + payload: { state: 'working', prompt: 'first launch', agentType: 'claude' } + }, + 'conn-1' + ) + server.retirePaneAuthority(PANE) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + isReplay: true, + payload: { state: 'working', prompt: 'stale replay', agentType: 'claude' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()).toEqual([]) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'UserPromptSubmit', + launchToken: 'retired-remote-launch-token', + payload: { state: 'working', prompt: 'remote manual restart', agentType: 'claude' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'working', + prompt: 'remote manual restart', + connectionId: 'conn-1' + }) + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: createHash('sha256').update('retired-remote-launch-token').digest('hex'), + connectionId: 'conn-1', + terminalProvenance: 'current_runtime' + }) + ).toBeNull() + }) + it('hydrates cached statuses as not observed in the current runtime', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-')) const firstServer = new AgentHookServer() @@ -2693,6 +3097,48 @@ describe('AgentHookServer listener replay', () => { } }) + it('does not apply Claude background evidence from a rejected local status', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postClaudeHook = async (payload: Record): Promise => { + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) + expect(response.status).toBe(204) + } + + await postClaudeHook({ + hook_event_name: 'PermissionRequest', + prompt: 'approve shell', + tool_name: 'Bash', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }], + session_crons: [{ id: 'cron-1', status: 'running' }] + }) + const waiting = server.getStatusSnapshot()[0] + + await postClaudeHook({ + hook_event_name: 'PreToolUse', + prompt: 'approve shell', + tool_name: 'OtherTool', + background_tasks: [], + session_crons: [] + }) + + expect(server.getStatusSnapshot()[0]).toEqual(waiting) + expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(true) + expect(server._getStateForTests().claudeActiveSessionCronPaneKeys.has(PANE)).toBe(true) + } finally { + server.stop() + } + }) + it('maps registered legacy numeric HTTP pane keys to stable pane keys', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -6261,6 +6707,13 @@ describe('Last-status persistence', () => { { launchToken: 'launch-bearer-must-not-persist' } ) ) + await postHookEvent( + server, + buildBody( + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }, + { launchToken: 'launch-bearer-must-not-persist' } + ) + ) // Synchronous flush via stop() captures the trailing-debounced write. server.flushStatusPersistSync() expect(existsSync(lastStatusPath())).toBe(true) @@ -6278,6 +6731,8 @@ describe('Last-status persistence', () => { expect(file.entries[PANE].launchTokenHash).toBe( createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) + expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() + expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') } finally { server.stop() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 8d5e57943b6..0f99f2cae9d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -41,7 +41,7 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots } from '../../shared/claude-subagent-roster' -import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay' import { CLAUDE_STATUSLINE_PATHNAME, parseClaudeStatusLineBody, @@ -85,9 +85,14 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & { stateStartedAt: number } +type NormalizedLocalHook = { + event: AgentHookEventPayload | null + onAccepted?: () => void +} + type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'launchToken' | 'promptInteractionKey' + 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' > & { launchTokenHash?: string } @@ -737,6 +742,14 @@ export class AgentHookServer { ) { return false } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. if ( payload.state !== 'working' || @@ -752,6 +765,14 @@ export class AgentHookServer { if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { return false } + // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + if ( + agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + ) { + return false + } // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. if (agentType === 'claude') { @@ -784,8 +805,7 @@ export class AgentHookServer { return true } - /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the - * renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */ + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -830,7 +850,7 @@ export class AgentHookServer { ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) - console.debug('[agent-hooks] inferred answered question status', { + console.debug('[agent-hooks] inferred resolved question status', { paneKey: inferred.paneKey, state: inferred.payload.state }) @@ -904,19 +924,28 @@ export class AgentHookServer { } } - private shouldSuppressClosedTabStatus(paneKey: string): boolean { + private getAgentStatusDisposition( + paneKey: string, + event?: { hookEventName?: string; isReplay?: boolean } + ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - if ( + const paneRetired = this.closedAgentStatusPaneKeys.has(paneKey) || this.closedAgentStatusPaneKeys.has(ownerPaneKey) - ) { - return true - } const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (!tabId) { - return false + if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + return 'suppress' } - return this.closedAgentStatusTabIds.has(tabId) + if (!paneRetired) { + return 'accept' + } + // Why: command completion retires launch authority but leaves its shell pane reusable. + if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + return 'restart' + } + return 'suppress' } private markPaneClosedForAgentStatus(paneKey: string): void { @@ -1030,7 +1059,10 @@ export class AgentHookServer { } } - private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { + private applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void + ): EnrichedAgentHookEventPayload { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -1044,6 +1076,7 @@ export class AgentHookServer { } if (payload.providerSessionOnly) { // Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status. + onAccepted?.() const enriched = this.attachStatusTiming(payload, now) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) @@ -1154,6 +1187,7 @@ export class AgentHookServer { ) { this.clearAssistantMessageRetry(effectivePayload.paneKey) } + onAccepted?.() if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } @@ -1295,13 +1329,13 @@ export class AgentHookServer { ) { return } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized?.payload.lastAssistantMessage) { + const normalized = this.normalizeLocalHookPayload(source, body) + if (!normalized.event?.payload.lastAssistantMessage) { this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) return } // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized) + this.applyNormalizedStatus(normalized.event, normalized.onAccepted) } setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { @@ -1622,6 +1656,48 @@ export class AgentHookServer { return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } } + private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { + if (source !== 'claude' || typeof body !== 'object' || body === null) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const rawPaneKey = (body as Record).paneKey + const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' + if (!paneKey) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const event = normalizeHookPayload(this.state, source, body, this.env) + const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + if (!event || event.paneKey !== paneKey) { + return { event } + } + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + return { + event, + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + } + } + + private setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void { + if (hasRunningTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + if (hasActiveCron) { + this.state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } + ingestTerminalStatus(event: { paneKey: string tabId?: string @@ -1649,7 +1725,7 @@ export class AgentHookServer { return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.shouldSuppressClosedTabStatus(paneKey)) { + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { return } const worktreeId = @@ -1718,6 +1794,9 @@ export class AgentHookServer { providerSession?: unknown providerSessionOnly?: unknown isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown payload: unknown }, connectionId: string @@ -1766,17 +1845,21 @@ export class AgentHookServer { return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.shouldSuppressClosedTabStatus(paneKey)) { + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + hookEventName, + isReplay: envelope.isReplay === true + }) + if (statusDisposition === 'suppress') { return } const worktreeId = envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 ? envelope.worktreeId.trim() : undefined - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined const promptInteractionKey = typeof envelope.promptInteractionKey === 'string' && envelope.promptInteractionKey.trim().length > 0 @@ -1796,16 +1879,27 @@ export class AgentHookServer { : undefined const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!normalizedPayload) { + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { return } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + const normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) if ( envelope.providerSessionOnly === true && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) ) { return } + const applyClaudeBackgroundWork = + normalizedPayload.agentType === 'claude' && + typeof envelope.claudeRunningNonAgentTask === 'boolean' && + // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. + (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. warnOnHookEnvOrVersionMismatch(this.state, { version: envelope.version, @@ -1814,7 +1908,7 @@ export class AgentHookServer { }) const event: AgentHookEventPayload = { paneKey, - launchToken: envelope.launchToken, + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, connectionId: trimmedConnectionId, @@ -1827,10 +1921,25 @@ export class AgentHookServer { providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, isReplay: envelope.isReplay === true ? true : undefined, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, payload: normalizedPayload } this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event) + this.applyNormalizedStatus( + event, + applyClaudeBackgroundWork + ? () => { + if (envelope.claudeRunningNonAgentTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + } + : undefined + ) } async start(options?: { @@ -1900,10 +2009,20 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) - const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env) - if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { - this.recordCurrentAuthorityObservation(normalized) - const enriched = this.applyNormalizedStatus(normalized) + const normalized = this.normalizeLocalHookPayload(source, aliasedBody) + const statusDisposition = normalized.event + ? this.getAgentStatusDisposition(normalized.event.paneKey, { + hookEventName: normalized.event.hookEventName, + isReplay: normalized.event.isReplay + }) + : 'suppress' + if (normalized.event && statusDisposition !== 'suppress') { + const event = + statusDisposition === 'restart' + ? { ...normalized.event, launchToken: undefined } + : normalized.event + this.recordCurrentAuthorityObservation(event) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) } @@ -2017,6 +2136,11 @@ export class AgentHookServer { // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) this.state.codexLeadStateByPaneKey.delete(paneKey) + } else if (deleted.payload.agentType === 'claude') { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + this.state.claudeLeadStateByPaneKey.delete(paneKey) + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) } } } @@ -2495,6 +2619,7 @@ export class AgentHookServer { continue } const { + claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, launchToken, ...persistedPayload diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index baf4ca6d322..014a511cea2 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -76,6 +76,7 @@ const aiVaultListResultSchema = z.object({ totalTokens: z.number(), previewMessages: z.array(aiVaultSessionPreviewMessageSchema), // Optional keeps paired hosts on older builds compatible. + firstUserPrompt: z.string().nullable().optional(), lastUserPrompt: z.string().nullable().optional(), // Default keeps remote hosts running an older build (no recoverable-signal // fields) parseable; they simply report no recoverable-empty sessions. diff --git a/src/main/ai-vault/session-first-user-prompt-read.test.ts b/src/main/ai-vault/session-first-user-prompt-read.test.ts new file mode 100644 index 00000000000..5213c49ab3e --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' + +const tempRoots: string[] = [] + +afterEach(async () => { + // Best-effort cleanup; tests are sandboxed under mkdtemp. + const { rm } = await import('node:fs/promises') + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('readAiVaultFirstUserPrompt', () => { + it('returns the full first user prompt without preview truncation', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const longPrompt = `Please implement the full vault first-prompt copy path.\n\n${'detail '.repeat(80).trimEnd()}` + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: false, + message: { role: 'user', content: longPrompt } + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'full-prompt-session', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'assistant', content: 'Working on it.', model: 'claude-sonnet-4-5' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.includes('\n\n')).toBe(true) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('extracts full Codex input_text content blocks (not preview-capped)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-codex-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-full.jsonl') + await mkdir(join(root, 'sessions', '2026', '07', '21'), { recursive: true }) + const longPrompt = `Review the PR and fix real regressions.\n\n${'context '.repeat(60).trimEnd()}` + await writeFile( + sessionPath, + [ + JSON.stringify({ + timestamp: '2026-07-21T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'codex-full-prompt', cwd: '/repo/app' } + }), + JSON.stringify({ + timestamp: '2026-07-21T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: longPrompt }] + } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'codex', + filePath: sessionPath, + codexHome: root + }) + + expect(result.prompt).toBe(longPrompt) + expect(result.prompt?.length).toBeGreaterThan(220) + }) + + it('skips meta/harness user turns and returns the first real ask', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-meta-')) + tempRoots.push(root) + const projectDir = join(root, 'project') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'session.jsonl') + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + isMeta: true, + message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' } + }), + JSON.stringify({ + type: 'user', + sessionId: 'meta-then-real', + timestamp: '2026-05-01T10:00:01.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'Ship the first-prompt copy button' } + }) + ].join('\n') + ) + + const result = await readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath + }) + + expect(result.prompt).toBe('Ship the first-prompt copy button') + }) + + it('resolves null instead of rejecting when the transcript is corrupt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-corrupt-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + // Grok's parser JSON.parses summary.json eagerly, so truncated JSON throws. + const summaryPath = join(sessionDir, 'summary.json') + await writeFile(summaryPath, '{"info": {"id": "session-1", "cwd": "/repo/a') + + await expect( + readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath }) + ).resolves.toEqual({ prompt: null }) + }) +}) diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts new file mode 100644 index 00000000000..2023303e4e6 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -0,0 +1,144 @@ +import { stat } from 'node:fs/promises' +import type { + AiVaultAgent, + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, + AiVaultSession +} from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { parseAgentSessionFile } from './session-scanner-agent-parser' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { FileWithMtime } from './session-scanner-types' + +export type ReadAiVaultFirstUserPromptArgs = { + agent: AiVaultAgent + filePath: string + sessionId?: string + executionHostId?: ExecutionHostId + codexHome?: string | null +} + +export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult + +/** IPC-safe entry: validates untyped payload then reads the full first prompt. */ +export async function handleAiVaultGetFirstUserPrompt( + args?: AiVaultFirstUserPromptArgs +): Promise { + if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { + return { prompt: null } + } + return readAiVaultFirstUserPrompt({ + agent: args.agent, + filePath: args.filePath, + sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, + executionHostId: args.executionHostId, + codexHome: args.codexHome + }) +} + +/** + * Re-parse one session transcript under full first-prompt capture and return + * the untruncated first real user ask for copy/reuse. + */ +export async function readAiVaultFirstUserPrompt( + args: ReadAiVaultFirstUserPromptArgs +): Promise { + const filePath = args.filePath.trim() + if (!filePath || !args.agent) { + return { prompt: null } + } + + // Why: transcript bodies live on the session host. Remote rows are skipped + // (same posture as listSubagentSessions); UI falls back to preview text. + const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID + if (executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return { prompt: null } + } + + // Why: partial/corrupt transcripts make parsers throw. Resolve null like every + // other unavailable case instead of rejecting the IPC call. + let session: AiVaultSession | null + try { + session = await withFullFirstUserPromptCapture(() => + parseSessionForFullFirstUserPrompt({ + agent: args.agent, + filePath, + sessionId: args.sessionId?.trim() || undefined, + codexHome: args.codexHome ?? null + }) + ) + } catch { + return { prompt: null } + } + + const prompt = session?.firstUserPrompt?.trim() || null + return { prompt } +} + +async function parseSessionForFullFirstUserPrompt(args: { + agent: AiVaultAgent + filePath: string + sessionId?: string + codexHome: string | null +}): Promise { + // Why: OpenCode SQLite sessions store filePath as the db path (not db#id). + // Re-parse in-process under full capture so ALS applies and we can read the + // earliest user row (worker list-scan path only joins newest messages). + if (args.agent === 'opencode') { + const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath) + if (fromSynthetic) { + return parseOpenCodeSqliteSession({ + dbPath: fromSynthetic.dbPath, + sessionId: fromSynthetic.sessionId, + platform: process.platform + }) + } + if (args.sessionId) { + return parseOpenCodeSqliteSession({ + dbPath: args.filePath, + sessionId: args.sessionId, + platform: process.platform + }) + } + } + + const file = await fileWithMtimeForPath(args.filePath) + if (!file) { + return null + } + + return parseAgentSessionFile( + { + agent: args.agent, + file, + codexHome: args.codexHome + }, + process.platform + ) +} + +async function fileWithMtimeForPath(filePath: string): Promise { + // OpenCode SQLite candidates use a synthetic `dbPath#sessionId` path that is + // not a real filesystem object; parsers that need it accept the path as-is. + if (filePath.includes('#')) { + return { + path: filePath, + mtimeMs: 0, + modifiedAt: new Date(0).toISOString() + } + } + + try { + const info = await stat(filePath) + return { + path: filePath, + mtimeMs: info.mtimeMs, + modifiedAt: info.mtime.toISOString(), + sizeBytes: info.size + } + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 3ce4cd1181d..97651c57109 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -12,6 +12,11 @@ import type { ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' +import { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { extractPreviewContentText, extractString, @@ -41,6 +46,7 @@ export function createAccumulator(args: { messageCount: 0, totalTokens: 0, previewMessages: [], + firstUserPrompt: null, lastUserPrompt: null, queuedMessageCount: 0, subagentTranscriptCount: 0, @@ -113,6 +119,7 @@ export function finalizeSession( messageCount: accumulator.messageCount, totalTokens: accumulator.totalTokens, previewMessages: accumulator.previewMessages, + ...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}), ...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}), queuedMessageCount: accumulator.queuedMessageCount, subagentTranscriptCount: accumulator.subagentTranscriptCount, @@ -149,6 +156,9 @@ export function addPreviewMessage( role: AiVaultSessionPreviewMessage['role'] text: string | null timestamp?: unknown + // Why: Claude meta/injected turns still preview, but must not seed the + // copyable first-prompt row. + seedFirstUserPrompt?: boolean } ): void { const text = normalizePreviewText(args.text ?? '') @@ -163,18 +173,40 @@ export function addPreviewMessage( if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) { accumulator.previewMessages.shift() } + // Why: list scans never store firstUserPrompt (payload/perf). Only the + // on-demand full-capture path seeds the untruncated copy body. + if ( + args.role === 'user' && + args.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() && + args.text + ) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text) + } } export function addPreviewContent( accumulator: SessionAccumulator, role: AiVaultSessionPreviewMessage['role'], content: unknown, - timestamp?: unknown + timestamp?: unknown, + options?: { seedFirstUserPrompt?: boolean } ): void { + if ( + role === 'user' && + options?.seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() + ) { + accumulator.firstUserPrompt = extractFullFirstUserPromptText(content) + } addPreviewMessage(accumulator, { role, text: extractPreviewContentText(content), - timestamp + timestamp, + // Content path already seeded above when capture is enabled. + seedFirstUserPrompt: false }) } diff --git a/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts new file mode 100644 index 00000000000..796fd273d0a --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt-capture.ts @@ -0,0 +1,15 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +// Why: list scans must not carry full first-prompt bodies (up to 500 sessions +// per refresh). On-demand copy re-parses one transcript under `full` mode. +export type FirstUserPromptCaptureMode = 'none' | 'full' + +const firstUserPromptCaptureStorage = new AsyncLocalStorage() + +export function getFirstUserPromptCaptureMode(): FirstUserPromptCaptureMode { + return firstUserPromptCaptureStorage.getStore() ?? 'none' +} + +export function withFullFirstUserPromptCapture(fn: () => Promise): Promise { + return firstUserPromptCaptureStorage.run('full', fn) +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.test.ts b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts new file mode 100644 index 00000000000..47235cbce12 --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { normalizeFullFirstUserPromptText } from './session-scanner-first-user-prompt' + +// Mirrors FULL_FIRST_USER_PROMPT_SAFETY_LIMIT in the module under test. +const SAFETY_LIMIT = 256 * 1024 + +describe('AI Vault full first-user-prompt normalization', () => { + it('drops an astral char straddling the safety limit instead of splitting it', () => { + const result = normalizeFullFirstUserPromptText(`${'a'.repeat(SAFETY_LIMIT - 1)}😀tail`) + + expect(result).toHaveLength(SAFETY_LIMIT - 1) + expect(result?.endsWith('a')).toBe(true) + expect(hasUnpairedSurrogate(result ?? '')).toBe(false) + }) + + it('keeps a prompt shorter than the safety limit intact', () => { + expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀') + }) +}) + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + const isHigh = code >= 0xd800 && code <= 0xdbff + const isLow = code >= 0xdc00 && code <= 0xdfff + if (isHigh) { + const next = value.charCodeAt(index + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) { + return true + } + index += 1 + continue + } + if (isLow) { + return true + } + } + return false +} diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.ts b/src/main/ai-vault/session-scanner-first-user-prompt.ts new file mode 100644 index 00000000000..4228abc9aaa --- /dev/null +++ b/src/main/ai-vault/session-scanner-first-user-prompt.ts @@ -0,0 +1,104 @@ +import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' +import { getFirstUserPromptCaptureMode } from './session-scanner-first-user-prompt-capture' +import { stripGrokUserQueryEnvelope } from './session-scanner-grok-user-text' +// Direct import: session-scanner-values re-exports this module, so going through +// it here would close an import cycle. +import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization' + +// Why: safety only for pathological multi-MB pastes. Copy path must not use the +// 220-char list preview cap. +const FULL_FIRST_USER_PROMPT_SAFETY_LIMIT = 256 * 1024 + +// Codex uses input_text; most others use text. Never treat tool/image blocks as +// the written first ask. +const TEXT_LIKE_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text']) + +/** True only while an on-demand first-prompt read is re-parsing one transcript. */ +export function shouldCaptureFullFirstUserPrompt(): boolean { + return getFirstUserPromptCaptureMode() === 'full' +} + +/** + * Extract the written first-user ask for copy/reuse. Preserves newlines and does + * not apply list-preview caps. Returns null for non-text / harness / empty. + */ +export function extractFullFirstUserPromptText(value: unknown): string | null { + if (typeof value === 'string') { + return finalizeFullFirstUserPrompt(value) + } + + // Single content block object (not wrapped in an array). + if (value && typeof value === 'object' && !Array.isArray(value)) { + const blockText = firstUserPromptContentItemText(value) + return blockText != null ? finalizeFullFirstUserPrompt(blockText) : null + } + + if (!Array.isArray(value)) { + return null + } + + const parts: string[] = [] + for (const item of value) { + const text = firstUserPromptContentItemText(item) + if (text != null) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return finalizeFullFirstUserPrompt(parts.join('\n')) +} + +export function normalizeFullFirstUserPromptText(value: string): string | null { + return finalizeFullFirstUserPrompt(value) +} + +function finalizeFullFirstUserPrompt(value: string): string | null { + // Why: Grok (and some pasted transcripts) wrap the real ask in ; + // strip that before copy so the clipboard is the typed prompt, not user_info. + const unwrapped = stripGrokUserQueryEnvelope(value.replace(/^\uFEFF/, '')) + const trimmed = unwrapped.trim() + if (!trimmed) { + return null + } + if (isSuppressedFullFirstUserPrompt(trimmed)) { + return null + } + if (isKnownHarnessInjectedUserTurnText(trimmed)) { + return null + } + // Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path. + const lower = trimmed.toLowerCase() + if (lower.startsWith('') && !lower.includes('')) { + return null + } + return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) +} + +function isSuppressedFullFirstUserPrompt(value: string): boolean { + const head = value.slice(0, 64).toLowerCase() + return head.startsWith('# agents.md instructions') || head.startsWith('') +} + +function firstUserPromptContentItemText(item: unknown): string | null { + if (typeof item === 'string') { + return item + } + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return null + } + const record = item as Record + const type = typeof record.type === 'string' ? record.type : null + if (type != null && !TEXT_LIKE_BLOCK_TYPES.has(type)) { + return null + } + if (typeof record.text === 'string' && record.text.length > 0) { + return record.text + } + // Some providers put the body on `content` for text-shaped blocks. + if (typeof record.content === 'string' && record.content.length > 0) { + return record.content + } + return null +} diff --git a/src/main/ai-vault/session-scanner-grok-parser.test.ts b/src/main/ai-vault/session-scanner-grok-parser.test.ts index 75c2d7575a6..bdbc19db468 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.test.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' -import { extractGrokContentText } from './session-scanner-grok-parser' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' +import { extractGrokContentText, parseGrokSessionFile } from './session-scanner-grok-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) describe('AI Vault Grok session parser', () => { it('extracts bounded user_query text without trimming the full body', () => { @@ -8,23 +19,94 @@ describe('AI Vault Grok session parser', () => { `context\n${'Grok prompt '.repeat(400)}` ) const trimCalls = trimSpy.mock.calls.length + trimSpy.mockRestore() - expect(trimCalls).toBe(0) + // stripGrokUserQueryEnvelope trims the body once; the fold must never trim + // per character (the input here is 4800+ chars). + expect(trimCalls).toBeGreaterThan(0) + expect(trimCalls).toBeLessThan(20) expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true) expect(result?.endsWith('...')).toBe(true) expect(result).not.toContain('USER_QUERY') + expect(result).not.toContain('USER_INFO') }) it('folds Grok array content without joining all text parts', () => { - const joinSpy = vi.spyOn(Array.prototype, 'join') const result = extractGrokContentText([ { type: 'text', text: 'Grok array '.repeat(80) }, { type: 'text', text: 'tail' } ]) - const joinCalls = joinSpy.mock.calls.length - expect(joinCalls).toBe(0) expect(result?.startsWith('Grok array Grok array')).toBe(true) expect(result?.endsWith('...')).toBe(true) }) + + it('drops an astral char straddling the preview scan cap instead of splitting it', () => { + // Hidden context is skipped by the fold, so the 4096-code-unit scan cap lands + // mid-emoji while the visible text stays well under the 220-char preview cap. + const hidden = `${'x'.repeat(4057)}` + const result = extractGrokContentText(`${hidden}ask😀tail`) + + expect(hidden).toHaveLength(4092) + expect(result).toBe('ask') + }) + + it('stores the unwrapped user_query as firstUserPrompt under full capture', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-first-')) + tempRoots.push(root) + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + const summaryPath = join(sessionDir, 'summary.json') + await writeFile( + summaryPath, + JSON.stringify({ + info: { id: 'session-1', cwd: '/repo/app' }, + generated_title: 'Grok title', + created_at: '2026-05-01T10:00:00.000Z', + updated_at: '2026-05-01T10:05:00.000Z' + }) + ) + const realAsk = 'fix i18n keep ko workspace worktree and primary' + await writeFile( + join(sessionDir, 'chat_history.jsonl'), + [ + JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths over absolute paths as tool call args when possible.', + '', + `\n${realAsk}\n` + ].join('\n') + } + ], + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'assistant', + content: 'On it.', + timestamp: '2026-05-01T10:00:02.000Z' + }) + ].join('\n') + ) + + const session = await withFullFirstUserPromptCapture(() => + parseGrokSessionFile({ + path: summaryPath, + mtimeMs: Date.now(), + modifiedAt: new Date().toISOString() + }) + ) + + expect(session?.firstUserPrompt).toBe(realAsk) + expect(session?.firstUserPrompt).not.toContain('user_info') + expect(session?.firstUserPrompt).not.toContain('OS Version') + }) }) diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 13620007447..46ed29e8f1f 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -11,6 +11,14 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' +import { + extractGrokFirstUserPromptText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' import { asRecord, extractPreviewContentText, @@ -18,7 +26,8 @@ import { normalizePreviewText, normalizeTitleText, numberValue, - parseJsonObject + parseJsonObject, + sliceAtCodeUnitLimit } from './session-scanner-values' const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096 @@ -68,14 +77,38 @@ async function consumeGrokChatHistory( if (role !== 'user' && role !== 'assistant') { continue } - const text = extractGrokContentText(record.content) + if (role === 'user') { - accumulator.title ??= normalizeTitleText(text ?? '') + // Why: first-prompt copy must be the typed ask inside , never + // the injected bootstrap row. + const firstPromptBody = extractGrokFirstUserPromptText(record.content) + const text = firstPromptBody + ? normalizePreviewText(capGrokPreviewSource(firstPromptBody)) + : null + + if (firstPromptBody) { + accumulator.title ??= normalizeTitleText(firstPromptBody) + if (shouldCaptureFullFirstUserPrompt() && !accumulator.firstUserPrompt) { + accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(firstPromptBody) + } + } + + if (text) { + addPreviewMessage(accumulator, { + role: 'user', + text, + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false + }) + } + continue } + addPreviewMessage(accumulator, { - role, - text, - timestamp: extractString(record.timestamp) + role: 'assistant', + text: extractGrokContentText(record.content), + timestamp: extractString(record.timestamp), + seedFirstUserPrompt: false }) } } catch { @@ -90,45 +123,11 @@ export function extractGrokContentText(value: unknown): string | null { return extractPreviewContentText(value) } +function capGrokPreviewSource(text: string): string { + return sliceAtCodeUnitLimit(text, GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) +} + function extractGrokStringContentText(text: string): string | null { - const bounds = grokUserQueryEnvelopeBounds(text) - if (!bounds) { - return normalizePreviewText(text) - } - - const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT) - return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text) -} - -function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null { - const opener = '' - const startIndex = indexOfAsciiIgnoreCase(text, opener, 0) - if (startIndex === -1) { - return null - } - const bodyStartIndex = startIndex + opener.length - const endIndex = indexOfAsciiIgnoreCase(text, '', bodyStartIndex) - if (endIndex === -1) { - return null - } - return { start: bodyStartIndex, end: endIndex } -} - -function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { - const lastStart = value.length - search.length - for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { - let matches = true - for (let offset = 0; offset < search.length; offset++) { - const code = value.charCodeAt(index + offset) - const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code - if (normalizedCode !== search.charCodeAt(offset)) { - matches = false - break - } - } - if (matches) { - return index - } - } - return -1 + const unwrapped = stripGrokUserQueryEnvelope(text) + return normalizePreviewText(capGrokPreviewSource(unwrapped)) } diff --git a/src/main/ai-vault/session-scanner-grok-user-text.test.ts b/src/main/ai-vault/session-scanner-grok-user-text.test.ts new file mode 100644 index 00000000000..0c6a6d830ea --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + extractGrokFirstUserPromptText, + isGrokBootstrapContextText, + stripGrokUserQueryEnvelope +} from './session-scanner-grok-user-text' + +describe('Grok first-user prompt text', () => { + it('unwraps user_query and drops the user_info bootstrap envelope', () => { + const raw = [ + '', + 'OS Version: macos', + 'Shell: /opt/homebrew/bin/bash', + 'Workspace Path: /Users/ada/repo', + "Today's date: 2026-08-01", + 'Note: Prefer using relative paths', + '', + '', + 'fix i18n keep ko workspace worktree and primary', + '' + ].join('\n') + + expect(extractGrokFirstUserPromptText(raw)).toBe( + 'fix i18n keep ko workspace worktree and primary' + ) + expect(stripGrokUserQueryEnvelope(raw)).toBe('fix i18n keep ko workspace worktree and primary') + }) + + it('unwraps user_query even when the closing tag is missing', () => { + const raw = 'context\nShip the full first prompt copy path' + expect(extractGrokFirstUserPromptText(raw)).toBe('Ship the full first prompt copy path') + }) + + it('rejects pure user_info bootstrap rows', () => { + const bootstrap = [ + '', + 'OS Version: macos', + 'Note: Prefer using relative paths over absolute paths', + '' + ].join('\n') + expect(isGrokBootstrapContextText(bootstrap)).toBe(true) + expect(extractGrokFirstUserPromptText(bootstrap)).toBeNull() + }) + + it('keeps ordinary user prompts', () => { + expect(extractGrokFirstUserPromptText('fix the flaky vault tests')).toBe( + 'fix the flaky vault tests' + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-grok-user-text.ts b/src/main/ai-vault/session-scanner-grok-user-text.ts new file mode 100644 index 00000000000..05ee7779b10 --- /dev/null +++ b/src/main/ai-vault/session-scanner-grok-user-text.ts @@ -0,0 +1,104 @@ +// Shared Grok user-turn text helpers for AI Vault (list preview + full first-prompt +// copy). Kept out of the native-chat decoder so vault scanners stay free of that +// dependency while matching its bootstrap / user_query rules. + +/** + * Prefer the body of a Grok `` envelope when present (closing tag + * optional). Bootstrap ``-only rows return null so a later real ask + * can seed the first prompt. + */ +export function extractGrokFirstUserPromptText(value: unknown): string | null { + const raw = flattenGrokUserContent(value) + if (!raw) { + return null + } + if (isGrokBootstrapContextText(raw)) { + return null + } + const unwrapped = stripGrokUserQueryEnvelope(raw) + const trimmed = unwrapped.trim() + if (!trimmed || isGrokBootstrapContextText(trimmed)) { + return null + } + // Why: if the turn is still just a user_info dump (no query envelope), it is + // not the prompt the user typed and must not be copied as "first prompt". + if ( + startsWithIgnoreCaseTag(trimmed, 'user_info') && + !containsIgnoreCaseTag(trimmed, 'user_query') + ) { + return null + } + return trimmed +} + +function flattenGrokUserContent(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (!item || typeof item !== 'object') { + continue + } + const record = item as Record + if (typeof record.type === 'string' && record.type !== 'text') { + continue + } + if (typeof record.text === 'string') { + parts.push(record.text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +export function stripGrokUserQueryEnvelope(text: string): string { + const opener = '' + const closer = '' + const lower = text.toLowerCase() + const start = lower.indexOf(opener) + if (start === -1) { + return text + } + const bodyStart = start + opener.length + const end = lower.indexOf(closer, bodyStart) + // Why: incomplete closing tag still holds the real ask after the opener. + if (end === -1) { + return text.slice(bodyStart).trim() + } + return text.slice(bodyStart, end).trim() +} + +export function isGrokBootstrapContextText(text: string): boolean { + const normalized = text.trim().toLowerCase() + if (!normalized.startsWith('')) { + return false + } + const userInfoEnd = normalized.indexOf('') + if (userInfoEnd === -1) { + // Open-ended user_info dump with no query: treat as bootstrap noise. + return !normalized.includes('') + } + const remainder = normalized.slice(userInfoEnd + ''.length).trim() + // Why: Grok appends a git snapshot to the bootstrap row; reject that envelope + // so real prompts mentioning either tag still count as user asks. + return ( + remainder.length === 0 || + (remainder.startsWith('') && remainder.endsWith('')) + ) +} + +function startsWithIgnoreCaseTag(text: string, tagName: string): boolean { + const lower = text.trimStart().toLowerCase() + return lower.startsWith(`<${tagName}>`) || lower.startsWith(`<${tagName} `) +} + +function containsIgnoreCaseTag(text: string, tagName: string): boolean { + return text.toLowerCase().includes(`<${tagName}>`) +} diff --git a/src/main/ai-vault/session-scanner-injected-title.test.ts b/src/main/ai-vault/session-scanner-injected-title.test.ts index 1b3071b7576..f0f566a79f5 100644 --- a/src/main/ai-vault/session-scanner-injected-title.test.ts +++ b/src/main/ai-vault/session-scanner-injected-title.test.ts @@ -134,5 +134,7 @@ describe('scanAiVaultSessions harness-injected title seeding', () => { expect(result.issues).toEqual([]) expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR') + // Meta skill preamble must not become the copyable first prompt. + expect(result.sessions[0]?.firstUserPrompt).toBeUndefined() }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index f0a85edf891..4f67393537a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -1,4 +1,4 @@ -import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { SessionFileCandidate } from './session-scanner-types' // Why: request/response shapes shared by the worker entry and the main-thread @@ -30,8 +30,6 @@ export type OpenCodeSqliteListValue = { issues: AiVaultScanIssue[] } -export type OpenCodeSqliteParseValue = AiVaultSession | null - export type OpenCodeSqliteWorkerResponse = | { id: number; ok: true; value: unknown } | { id: number; ok: false; error: string } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts index 29f3685add1..f7e042d663b 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' let tempDirs: string[] = [] @@ -496,4 +497,107 @@ describe('parseOpenCodeSqliteSession', () => { expect(session).not.toBeNull() expect(session!.model).toBe('claude-sonnet-4-5') }) + + it('captures every text part of the earliest user message and no later turn', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 10, + text: 'first ask line one' + }) + insertPart(db, { + id: 'part_1b', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 20, + text: 'first ask line two' + }) + // Non-text parts of the same message must not leak into the copied prompt. + insertPart(db, { + id: 'part_1c', + messageId: 'msg_1', + sessionId: 'ses_fp', + timeCreated: 30, + type: 'tool', + text: 'tool output blob' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp', + timeCreated: 40, + text: 'a later ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two') + }) + + it('skips an earliest user message that has no text parts', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_fp2', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_900_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_000_000 + }) + insertPart(db, { + id: 'part_1a', + messageId: 'msg_1', + sessionId: 'ses_fp2', + timeCreated: 10, + type: 'tool', + text: 'tool only' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_fp2', + role: 'user', + timeCreated: 1_777_634_500_000 + }) + insertPart(db, { + id: 'part_2a', + messageId: 'msg_2', + sessionId: 'ses_fp2', + timeCreated: 40, + text: 'the real typed ask' + }) + db.close() + + const session = await withFullFirstUserPromptCapture(() => + parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' }) + ) + + expect(session!.firstUserPrompt).toBe('the real typed ask') + }) }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index ad3e9b76f41..d247156b8cf 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -5,6 +5,10 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' import { normalizeTitleText } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -22,6 +26,8 @@ const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 // id) index. Bounds the read to those messages' parts; the 15 s parse timeout // caps the residual for a single pathological giant part. const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100 +// Bounds a pathological single message; a real typed prompt is a handful of parts. +const FIRST_USER_PROMPT_PART_LIMIT = 512 type SessionRow = { id: string @@ -154,6 +160,58 @@ function extractPartText(partData: string): string | null { } } +function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + + try { + // Why: pin to the single earliest user message that actually has text parts, + // then take all of its parts. Ordering parts across every user message would + // pad a short first prompt with later turns and truncate a long one. + const rows = db + .prepare( + `SELECT p.data AS part_data + FROM part p + WHERE p.message_id = ( + SELECT m.id + FROM message m + JOIN part fp ON fp.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(fp.data, '$.type') = 'text' + ORDER BY m.time_created ASC, m.id ASC + LIMIT 1 + ) + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created ASC, p.rowid ASC + LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` + ) + .all(sessionId) as { part_data: string }[] + + const parts: string[] = [] + for (const row of rows) { + const text = extractPartText(row.part_data) + if (text) { + parts.push(text) + } + } + if (parts.length === 0) { + return null + } + return normalizeFullFirstUserPromptText(parts.join('\n')) + } catch { + return null + } +} + function buildPreviewQuery(db: SyncDatabase): string | null { if ( !canCountOpenCodeMessages(db) || @@ -254,7 +312,9 @@ export async function parseOpenCodeSqliteSession(args: { addPreviewMessage(accumulator, { role: mapPreviewRole(previewRow.role), text, - timestamp: previewRow.time_created + timestamp: previewRow.time_created, + // Preview window is newest-N; first-prompt is loaded separately below. + seedFirstUserPrompt: false }) if (previewRow.role === 'user' && !accumulator.title) { accumulator.title = @@ -264,6 +324,12 @@ export async function parseOpenCodeSqliteSession(args: { } } + // Why: list preview only joins the newest messages. On-demand copy needs the + // session's earliest real user text part, not a later turn still in the window. + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId) + } + return finalizeSession(accumulator, platform) } finally { db?.close() diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index c7e67ec463b..537055d87d4 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -127,13 +127,17 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s if (record.type === 'user') { accumulator.messageCount++ const title = extractMessageText(record.message) - addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + // Meta prompts (injected context) only seed the last-resort title. Some + // injected turns (task notifications) carry no isMeta, so also gate on + // the known-tag classifier — a real prompt pasting a custom `` + // must seed the primary title, not be demoted as machinery. + const isMetaUserTurn = + record.isMeta === true || (title != null && isKnownHarnessInjectedUserTurnText(title)) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp, { + seedFirstUserPrompt: !isMetaUserTurn + }) if (title) { - // Meta prompts (injected context) only seed the last-resort title. Some - // injected turns (task notifications) carry no isMeta, so also gate on - // the known-tag classifier — a real prompt pasting a custom `` - // must seed the primary title, not be demoted as machinery. - if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) { + if (isMetaUserTurn) { state.metaTitle ??= title } else { state.firstUserTitle ??= title diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index 364c25b5d85..cb472aaefe9 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -18,13 +18,13 @@ import { sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' +import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt' import { arrayValue, asRecord, copilotModelMetricsTotal, extractContentText, extractMessageText, - extractPreviewContentText, extractString, extractTrustedFolder, findOpenCodeStorageRoot, @@ -253,10 +253,12 @@ export async function consumeOpenCodeMessages( accumulator.title ??= extractString(asRecord(message.summary)?.title) accumulator.title ??= extractString(asRecord(message.summary)?.body) } + // Why: pass raw body text so full first-prompt capture is not stuck on the + // 220-char preview fold (addPreviewMessage preview-caps for display). addPreviewMessage(accumulator, { role, text: - extractPreviewContentText(message.content) ?? + extractFullFirstUserPromptText(message.content) ?? extractString(asRecord(message.summary)?.body) ?? extractString(asRecord(message.summary)?.title), timestamp: timeObjectValue(message.time, 'created') diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 88eae73eb2e..99f73a93d02 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -42,6 +42,15 @@ export function normalizePreviewText(value: string): string | null { return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT)) } +/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */ +export function sliceAtCodeUnitLimit(value: string, limit: number): string { + if (value.length <= limit) { + return value + } + const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit + return value.slice(0, end) +} + function normalizeContentText(value: unknown, limit: number): string | null { if (typeof value === 'string') { return finalizeNormalizedText(normalizeStringText(value, limit)) @@ -112,15 +121,18 @@ function appendInterPartSpace(builder: TextBuilder): void { } } -function appendNormalizedString(builder: TextBuilder, value: string): void { +function appendNormalizedString(builder: TextBuilder, value: string, maxScanLength?: number): void { + const scanEnd = maxScanLength == null ? value.length : Math.min(value.length, maxScanLength) let index = 0 - while (index < value.length && !builder.truncated) { + while (index < scanEnd && !builder.truncated) { const hiddenBlockEnd = hiddenTextBlockEnd(value, index) if (hiddenBlockEnd !== null) { if (builder.text.length > 0) { builder.pendingSpace = true } - index = hiddenBlockEnd + // Why: hidden blocks may jump past the scan budget; clamp so multi-MB + // suppressed context cannot keep the first-prompt path busy. + index = Math.min(hiddenBlockEnd, scanEnd) continue } @@ -142,9 +154,17 @@ function appendNormalizedString(builder: TextBuilder, value: string): void { } const charLength = codePointLength(value, index) + // Why: do not read past scanEnd mid code-point when the budget lands inside + // a surrogate pair — drop the incomplete char instead. + if (index + charLength > scanEnd) { + break + } appendVisibleText(builder, value.slice(index, index + charLength)) index += charLength } + if (!builder.truncated && scanEnd < value.length && builder.text.length > 0) { + builder.truncated = true + } } function appendVisibleText(builder: TextBuilder, value: string): void { @@ -230,9 +250,7 @@ function isSuppressedContextPrefix(value: string): boolean { } function truncateWithEllipsis(value: string, limit: number): string { - const end = Math.max(0, limit - ELLIPSIS.length) - const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end - return `${value.slice(0, safeEnd)}${ELLIPSIS}` + return `${sliceAtCodeUnitLimit(value, Math.max(0, limit - ELLIPSIS.length))}${ELLIPSIS}` } function objectRecord(value: unknown): Record | null { diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index ac94a067d2a..cc8f4939aa2 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -110,6 +110,7 @@ export type SessionAccumulator = { messageCount: number totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] + firstUserPrompt: string | null lastUserPrompt: string | null // Recoverable signal for a zero-turn transcript (see AiVaultSession). queuedMessageCount: number diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index a9954ae8d26..3ca34327299 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { + extractFullFirstUserPromptText, extractPreviewContentText, normalizeAgentSessionsDir, + normalizeFullFirstUserPromptText, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -53,6 +55,23 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + it('preserves full first-prompt text including newlines for copy', () => { + const body = `First prompt line one\n\nline two ${'word '.repeat(100)}` + expect(normalizeFullFirstUserPromptText(body)).toBe(body.trim()) + expect(extractFullFirstUserPromptText([{ type: 'text', text: body }])).toBe(body.trim()) + }) + + it('reads Codex input_text blocks and ignores tool blocks', () => { + const body = `Review the PR\n\n${'detail '.repeat(50).trimEnd()}` + expect(extractFullFirstUserPromptText([{ type: 'input_text', text: body }])).toBe(body) + expect( + extractFullFirstUserPromptText([ + { type: 'tool_result', content: 'src/main/window.ts was updated' }, + { type: 'text', text: 'Please continue the editor refactor' } + ]) + ).toBe('Please continue the editor refactor') + }) + it('expands Pi and OMP agent homes to their session directories', () => { expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 1deb8bf5dc9..10ec34cd15e 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -55,8 +55,14 @@ export { extractMessageText, extractPreviewContentText, normalizePreviewText, - normalizeTitleText + normalizeTitleText, + sliceAtCodeUnitLimit } from './session-scanner-text-normalization' +export { + extractFullFirstUserPromptText, + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' export function extractGitBranch(value: unknown): string | null { const git = asRecord(value) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 7ba80556051..7c472d6f3eb 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -174,6 +174,8 @@ describe('scanAiVaultSessions', () => { totalTokens: 155, resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'" }) + // Why: list scans omit firstUserPrompt so the vault payload stays bounded. + expect(claude?.firstUserPrompt).toBeUndefined() const codex = result.sessions.find((session) => session.agent === 'codex') expect(codex).toMatchObject({ @@ -185,6 +187,7 @@ describe('scanAiVaultSessions', () => { totalTokens: 625, resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'` }) + expect(codex?.firstUserPrompt).toBeUndefined() }) it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => { diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts index dfcb4c82b4e..02a4b4fb74a 100644 --- a/src/main/azure-devops/pull-request-creation.test.ts +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -4,6 +4,7 @@ import { isAzureDevOpsReviewCreationAuthenticated } from './pull-request-creation' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), @@ -15,7 +16,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 0 })) vi.mock('../source-control/pull-request-template', () => ({ @@ -128,7 +130,9 @@ describe('Azure DevOps pull request creation', () => { ok: true, number: 38 }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo') + expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -149,7 +153,8 @@ describe('Azure DevOps pull request creation', () => { code: 'auth_required' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) }) diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 7eaed82a702..4a2a1be1219 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -16,6 +16,7 @@ import { parseAzureDevOpsRepoRef } from './repository-ref' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('parseAzureDevOpsRepoRef', () => { beforeEach(() => { @@ -117,7 +118,9 @@ describe('parseAzureDevOpsRepoRef', () => { webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' }) - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -151,11 +154,13 @@ describe('parseAzureDevOpsRepoRef', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { cwd: '/repo', - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts index 868d0d8c9bc..3c014daa670 100644 --- a/src/main/azure-devops/repository-ref.ts +++ b/src/main/azure-devops/repository-ref.ts @@ -1,5 +1,4 @@ -import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache' export type AzureDevOpsRepoRef = { host: string @@ -14,28 +13,16 @@ type LocalGitExecOptions = { wslDistro?: string } -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefProbeCache = createRemoteRefProbeCache(parseAzureDevOpsRepoRef) /** @internal - exposed for tests only */ export function _resetAzureDevOpsRepoRefCache(): void { - repoRefCache.clear() + repoRefProbeCache.clear() } /** @internal - exposed for tests only */ export function _getAzureDevOpsRepoRefCacheSize(): number { - return repoRefCache.size -} - -function rememberRepoRefCacheEntry(cacheKey: string, value: AzureDevOpsRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } + return repoRefProbeCache.size() } function decodeSegment(value: string): string { @@ -211,34 +198,7 @@ export async function getAzureDevOpsRepoRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! - } - try { - const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !sshGitProvider) { - return null - } - const { stdout } = sshGitProvider - ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) - : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - const result = parseAzureDevOpsRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) - return result - } catch { - if (connectionId) { - // Why: SSH provider failures are often transient reconnect/tunnel states; - // caching them as "not Azure DevOps" would poison the repo for the session. - return null - } - rememberRepoRefCacheEntry(cacheKey, null) - return null - } + return repoRefProbeCache.get(repoPath, remoteName, connectionId, localGitOptions) } export async function getAzureDevOpsRepoRef( diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index c29b3539927..603e4fb8a6c 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -17,6 +17,7 @@ import { parseBitbucketRepoRef } from './repository-ref' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Bitbucket repository refs', () => { beforeEach(() => { @@ -90,7 +91,8 @@ describe('Bitbucket repository refs', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -120,11 +122,13 @@ describe('Bitbucket repository refs', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { cwd: '/repo', - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -153,7 +157,9 @@ describe('Bitbucket repository refs', () => { repoSlug: 'project' }) - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index fb07c32d4bc..8456b2d4ae3 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -1,5 +1,4 @@ -import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache' export type BitbucketRepoRef = { workspace: string @@ -10,28 +9,16 @@ type LocalGitExecOptions = { wslDistro?: string } -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefProbeCache = createRemoteRefProbeCache(parseBitbucketRepoRef) /** @internal - exposed for tests only */ export function _resetBitbucketRepoRefCache(): void { - repoRefCache.clear() + repoRefProbeCache.clear() } /** @internal - exposed for tests only */ export function _getBitbucketRepoRefCacheSize(): number { - return repoRefCache.size -} - -function rememberRepoRefCacheEntry(cacheKey: string, value: BitbucketRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } + return repoRefProbeCache.size() } function decodeSegment(value: string): string { @@ -86,34 +73,7 @@ export async function getBitbucketRepoRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! - } - try { - const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !sshGitProvider) { - return null - } - const { stdout } = sshGitProvider - ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) - : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - const result = parseBitbucketRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) - return result - } catch { - if (connectionId) { - // Why: SSH provider failures are often transient reconnect/tunnel states; - // caching them as "not Bitbucket" would poison the repo for the session. - return null - } - rememberRepoRefCacheEntry(cacheKey, null) - return null - } + return repoRefProbeCache.get(repoPath, remoteName, connectionId, localGitOptions) } export async function getBitbucketRepoRef( diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index 565f7e49c81..e6c4f0fc8dc 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -4,7 +4,6 @@ import { join, basename } from 'node:path' import { realpath, readdir, stat } from 'node:fs/promises' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' -import type { Repo } from '../../shared/types' import type { ClaudeUsageAttributedTurn, ClaudeUsageDailyAggregate, @@ -170,24 +169,6 @@ export async function listClaudeTranscriptFiles(): Promise { return [...new Set(files.flat())].sort() } -export async function getProcessedFileInfo(filePath: string): Promise { - const fileStat = await stat(filePath) - let lineCount = 0 - const lines = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const _line of lines) { - lineCount++ - } - return { - path: filePath, - mtimeMs: fileStat.mtimeMs, - size: fileStat.size, - lineCount - } -} - async function getProcessedFileStat( filePath: string ): Promise> { @@ -757,24 +738,6 @@ export async function scanClaudeUsageFiles( } } -export function createWorktreeRefs( - repos: Repo[], - worktreesByRepo: Map -): ClaudeUsageWorktreeRef[] { - const refs: ClaudeUsageWorktreeRef[] = [] - for (const repo of repos) { - for (const worktree of worktreesByRepo.get(repo.id) ?? []) { - refs.push({ - repoId: repo.id, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - } - } - return refs -} - export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string { if (locationBreakdown.length === 0) { return 'Unknown location' @@ -784,7 +747,3 @@ export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBre } return 'Multiple locations' } - -export function getDefaultWorktreeLabel(pathValue: string): string { - return basename(pathValue) -} diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index e213292820c..180f7660b6b 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -18,7 +18,8 @@ import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { createWorktreeRefs } from '../usage/usage-worktree-refs' +import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 96cbe301dcf..67b66c34a93 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -67,10 +67,10 @@ function getManagedScript( `if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}` ] : []), - // Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing. + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - // Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook. + // Why: use curl.exe to avoid an extra PowerShell startup per hook. buildWindowsAgentHookCurlPostCommand('claude'), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), @@ -89,16 +89,16 @@ function getManagedScript( 'fi' ] : []), - // Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing. - // Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e. + // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. + // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse. - // Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). + // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -136,7 +136,7 @@ export class ClaudeHookService { } } - // Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`. + // Why: report partial registration instead of a false installed state. const command = getManagedCommand(scriptPath) const missing: string[] = [] let presentCount = 0 @@ -227,11 +227,11 @@ export class ClaudeHookService { // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - // Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this. + // Why: remote Windows is unsupported; local process.platform cannot identify the remote OS. const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` - // Why: SFTP I/O fails often (network/EACCES/disk); wrap install so transient failures surface as structured state:'error' rather than an unhandled rejection. + // Why: surface fallible SFTP installs as structured errors. try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -248,8 +248,8 @@ export class ClaudeHookService { const command = getRemoteManagedCommand(remoteScriptPath) const nextConfig = applyManagedHooks(config, command, remoteScriptFileName) - // Why: write script before settings — a mid-install failure then leaves a harmless orphan script, not settings.json pointing at a missing one. - // Why: SSH remotes use POSIX `.sh` paths even when Orca runs on Windows; never derive remote script syntax from the local OS. + // Why: write scripts before settings to avoid settings pointing to missing scripts. + // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. await writeManagedScriptRemote( sftp, remoteScriptPath, diff --git a/src/main/codex-usage/codex-usage-provider.ts b/src/main/codex-usage/codex-usage-provider.ts new file mode 100644 index 00000000000..a831401deb3 --- /dev/null +++ b/src/main/codex-usage/codex-usage-provider.ts @@ -0,0 +1,20 @@ +import type { UsageProvider } from '../usage/usage-provider-contract' +import { scanCodexUsageFiles } from './scanner' +import type { CodexUsageDailyAggregate, CodexUsagePersistedFile, CodexUsageSession } from './types' + +// Why: v5 keys Codex ownership on raw token_count identity without session id +// so forks that rewrite session_meta still match. Older caches used session- +// scoped keys and can double-count after fork/resume (#8006). +export const CODEX_USAGE_SCHEMA_VERSION = 5 + +export const codexUsageProvider = { + id: 'codex', + label: 'Codex', + schemaVersion: CODEX_USAGE_SCHEMA_VERSION, + scan: scanCodexUsageFiles +} satisfies UsageProvider< + 'processedFiles', + CodexUsagePersistedFile, + CodexUsageSession, + CodexUsageDailyAggregate +> diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index eb5fbcaab14..20d22c00d71 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -3,30 +3,29 @@ import { basename, join, win32, posix } from 'node:path' import { createReadStream, existsSync } from 'node:fs' import { realpath, readdir, stat } from 'node:fs/promises' import { createInterface } from 'node:readline' -import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' +import { createUsageEventAggregation } from '../usage/usage-event-aggregation' +import { + looksLikeWindowsPath, + normalizeComparablePath, + normalizeFsPath +} from '../usage/usage-path-comparison' +import { ensureNumber, extractString } from '../usage/usage-record-coercion' +import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, - CodexUsageLocationBreakdown, - CodexUsageLocationModelBreakdown, - CodexUsageModelBreakdown, CodexUsageParsedEvent, CodexUsagePersistedFile, CodexUsageProcessedFile, CodexUsageSession } from './types' -export type CodexUsageWorktreeRef = { - repoId: string - worktreeId: string - path: string - displayName: string -} +export type CodexUsageWorktreeRef = UsageScanWorktreeRef type CodexUsageRawRecord = { timestamp?: string @@ -58,28 +57,6 @@ type CodexUsageDeltaResolution = const YIELD_EVERY_FILES = 10 const YIELD_EVERY_DISCOVERY_ENTRIES = 100 -function ensureNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : 0 -} - -function normalizeComparablePath(pathValue: string, platform = process.platform): string { - const normalized = pathValue.replace(/\\/g, '/') - return platform === 'win32' || looksLikeWindowsPath(pathValue) - ? normalized.toLowerCase() - : normalized -} - -function normalizeFsPath(pathValue: string, platform = process.platform): string { - if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { - return win32.normalize(win32.resolve(pathValue)) - } - return posix.normalize(posix.resolve(pathValue)) -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') -} - async function canonicalizePath(pathValue: string): Promise { try { const resolved = await realpath(pathValue) @@ -414,14 +391,6 @@ function buildCodexUsageEventKey( return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|') } -function extractString(value: unknown): string | null { - if (typeof value !== 'string') { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} - function extractModel(value: unknown): string | null { if (value == null || typeof value !== 'object') { return null @@ -568,331 +537,29 @@ export async function attributeCodexUsageEvent( } } -function createEmptySession(event: CodexUsageAttributedEvent): CodexUsageSession { - return { - sessionId: event.sessionId, - firstTimestamp: event.timestamp, - lastTimestamp: event.timestamp, - primaryModel: event.model, - hasMixedModels: false, - primaryProjectLabel: event.projectLabel, - hasMixedLocations: false, - primaryWorktreeId: event.worktreeId, - primaryRepoId: event.repoId, - eventCount: 0, - totalInputTokens: 0, - totalCachedInputTokens: 0, - totalOutputTokens: 0, - totalReasoningOutputTokens: 0, - totalTokens: 0, - hasInferredPricing: false, - locationBreakdown: [], - modelBreakdown: [], - locationModelBreakdown: [] - } -} +type CodexUsageMetric = { hasInferredPricing: boolean } -function createEmptyDailyAggregate(event: CodexUsageAttributedEvent): CodexUsageDailyAggregate { - return { - day: event.day, - model: event.model, - projectKey: event.projectKey, - projectLabel: event.projectLabel, - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - hasInferredPricing: false - } -} - -function mergeLocationBreakdown( - target: CodexUsageLocationBreakdown[], - event: CodexUsageAttributedEvent -): void { - const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.hasInferredPricing ||= event.hasInferredPricing - return - } - - target.push({ - locationKey: event.projectKey, - projectLabel: event.projectLabel, - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - hasInferredPricing: event.hasInferredPricing - }) -} - -function mergeModelBreakdown( - target: CodexUsageModelBreakdown[], - event: CodexUsageAttributedEvent -): void { - const key = event.model ?? 'unknown' - const existing = target.find((entry) => entry.modelKey === key) ?? null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.hasInferredPricing ||= event.hasInferredPricing - return - } - - target.push({ - modelKey: key, - modelLabel: event.model ?? 'Unknown model', - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - hasInferredPricing: event.hasInferredPricing - }) -} - -function mergeLocationModelBreakdown( - target: CodexUsageLocationModelBreakdown[], - event: CodexUsageAttributedEvent -): void { - const modelKey = event.model ?? 'unknown' - const existing = - target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? - null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.hasInferredPricing ||= event.hasInferredPricing - return - } - - target.push({ - locationKey: event.projectKey, - modelKey, - modelLabel: event.model ?? 'Unknown model', - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - hasInferredPricing: event.hasInferredPricing - }) -} - -function aggregateCodexUsage(events: CodexUsageAttributedEvent[]): { - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} { - const sessionsById = new Map() - const dailyByKey = new Map() - - for (const event of events) { - const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) - if (!sessionsById.has(event.sessionId)) { - sessionsById.set(event.sessionId, session) +const codexUsageAggregation = createUsageEventAggregation< + CodexUsageAttributedEvent, + CodexUsageMetric +>({ + metric: { + empty: () => ({ hasInferredPricing: false }), + fromEvent: (event) => ({ hasInferredPricing: event.hasInferredPricing }), + fold: (target, source) => { + target.hasInferredPricing ||= source.hasInferredPricing } - if (event.timestamp < session.firstTimestamp) { - session.firstTimestamp = event.timestamp - } - if (event.timestamp >= session.lastTimestamp) { - session.lastTimestamp = event.timestamp - } - session.eventCount++ - session.totalInputTokens += event.inputTokens - session.totalCachedInputTokens += event.cachedInputTokens - session.totalOutputTokens += event.outputTokens - session.totalReasoningOutputTokens += event.reasoningOutputTokens - session.totalTokens += event.totalTokens - session.hasInferredPricing ||= event.hasInferredPricing - mergeLocationBreakdown(session.locationBreakdown, event) - mergeModelBreakdown(session.modelBreakdown, event) - mergeLocationModelBreakdown(session.locationModelBreakdown, event) - - const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') - const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) - if (!dailyByKey.has(dailyKey)) { - dailyByKey.set(dailyKey, daily) - } - daily.eventCount++ - daily.inputTokens += event.inputTokens - daily.cachedInputTokens += event.cachedInputTokens - daily.outputTokens += event.outputTokens - daily.reasoningOutputTokens += event.reasoningOutputTokens - daily.totalTokens += event.totalTokens - daily.hasInferredPricing ||= event.hasInferredPricing - } - - return { - sessions: finalizeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) - } -} - -function finalizeSessions(sessionsById: Map): CodexUsageSession[] { - for (const session of sessionsById.values()) { - session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) - session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) - const primaryLocation = session.locationBreakdown[0] ?? null - const primaryModel = session.modelBreakdown[0] ?? null - session.primaryProjectLabel = - session.locationBreakdown.length <= 1 - ? (primaryLocation?.projectLabel ?? 'Unknown location') - : 'Multiple locations' - session.hasMixedLocations = session.locationBreakdown.length > 1 - session.primaryWorktreeId = primaryLocation?.worktreeId ?? null - session.primaryRepoId = primaryLocation?.repoId ?? null - session.primaryModel = - session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' - session.hasMixedModels = session.modelBreakdown.length > 1 - } - - return [...sessionsById.values()].sort((left, right) => - right.lastTimestamp.localeCompare(left.lastTimestamp) - ) -} - -function mergeSessions( - target: Map, - sessions: CodexUsageSession[] -): void { - for (const session of sessions) { - const existing = target.get(session.sessionId) - if (!existing) { - target.set(session.sessionId, cloneSessionForMerge(session)) - continue - } - - existing.firstTimestamp = - session.firstTimestamp < existing.firstTimestamp - ? session.firstTimestamp - : existing.firstTimestamp - existing.lastTimestamp = - session.lastTimestamp > existing.lastTimestamp - ? session.lastTimestamp - : existing.lastTimestamp - existing.eventCount += session.eventCount - existing.totalInputTokens += session.totalInputTokens - existing.totalCachedInputTokens += session.totalCachedInputTokens - existing.totalOutputTokens += session.totalOutputTokens - existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens - existing.totalTokens += session.totalTokens - existing.hasInferredPricing ||= session.hasInferredPricing - - for (const location of session.locationBreakdown) { - const existingLocation = - existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? - null - if (existingLocation) { - existingLocation.eventCount += location.eventCount - existingLocation.inputTokens += location.inputTokens - existingLocation.cachedInputTokens += location.cachedInputTokens - existingLocation.outputTokens += location.outputTokens - existingLocation.reasoningOutputTokens += location.reasoningOutputTokens - existingLocation.totalTokens += location.totalTokens - existingLocation.hasInferredPricing ||= location.hasInferredPricing - } else { - existing.locationBreakdown.push({ ...location }) - } - } - - for (const model of session.modelBreakdown) { - const existingModel = - existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null - if (existingModel) { - existingModel.eventCount += model.eventCount - existingModel.inputTokens += model.inputTokens - existingModel.cachedInputTokens += model.cachedInputTokens - existingModel.outputTokens += model.outputTokens - existingModel.reasoningOutputTokens += model.reasoningOutputTokens - existingModel.totalTokens += model.totalTokens - existingModel.hasInferredPricing ||= model.hasInferredPricing - } else { - existing.modelBreakdown.push({ ...model }) - } - } - - for (const locationModel of session.locationModelBreakdown) { - const existingLocationModel = - existing.locationModelBreakdown.find( - (entry) => - entry.locationKey === locationModel.locationKey && - entry.modelKey === locationModel.modelKey - ) ?? null - if (existingLocationModel) { - existingLocationModel.eventCount += locationModel.eventCount - existingLocationModel.inputTokens += locationModel.inputTokens - existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens - existingLocationModel.outputTokens += locationModel.outputTokens - existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens - existingLocationModel.totalTokens += locationModel.totalTokens - existingLocationModel.hasInferredPricing ||= locationModel.hasInferredPricing - } else { - existing.locationModelBreakdown.push({ ...locationModel }) - } - } - } -} - -function cloneSessionForMerge(session: CodexUsageSession): CodexUsageSession { - return { + }, + cloneSessionForMerge: (session) => ({ ...session, locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })), modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })), locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry })) - } -} + }) +}) -function mergeDailyAggregates( - target: Map, - dailyAggregates: CodexUsageDailyAggregate[] -): void { - for (const aggregate of dailyAggregates) { - const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') - const existing = target.get(key) - if (!existing) { - target.set(key, { ...aggregate }) - continue - } - existing.eventCount += aggregate.eventCount - existing.inputTokens += aggregate.inputTokens - existing.cachedInputTokens += aggregate.cachedInputTokens - existing.outputTokens += aggregate.outputTokens - existing.reasoningOutputTokens += aggregate.reasoningOutputTokens - existing.totalTokens += aggregate.totalTokens - existing.hasInferredPricing ||= aggregate.hasInferredPricing - } -} +const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregates } = + codexUsageAggregation export function parseCodexUsageRecord( line: string, @@ -1042,7 +709,7 @@ export async function parseCodexUsageFile( return { ...processedFile, - ...aggregateCodexUsage(events), + ...codexUsageAggregation.aggregate(events), ownedEventKeys: [...ownedEventKeys], hasDeferredClaims } @@ -1154,42 +821,6 @@ export async function scanCodexUsageFiles( return { processedFiles, sessions: finalizeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) + dailyAggregates: sortDailyAggregates(dailyByKey) } } - -export function createWorktreeRefs( - repos: Repo[], - worktreesByRepo: Map -): CodexUsageWorktreeRef[] { - const refs: CodexUsageWorktreeRef[] = [] - for (const repo of repos) { - for (const worktree of worktreesByRepo.get(repo.id) ?? []) { - refs.push({ - repoId: repo.id, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - } - } - return refs -} - -export function getDefaultWorktreeLabel(pathValue: string): string { - return basename(pathValue) -} - -export function getSessionProjectLabel(locationBreakdown: CodexUsageLocationBreakdown[]): string { - if (locationBreakdown.length === 0) { - return 'Unknown location' - } - if (locationBreakdown.length === 1) { - return locationBreakdown[0].projectLabel - } - return 'Multiple locations' -} diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 11be8c5149a..83e5a43fabd 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -52,7 +52,6 @@ vi.mock('node:fs/promises', async () => { }) vi.mock('./scanner', () => ({ - createWorktreeRefs: vi.fn(() => []), scanCodexUsageFiles: vi.fn() })) diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 540d8f23e27..10aad99fbbf 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -18,12 +18,10 @@ import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' +import { createWorktreeRefs } from '../usage/usage-worktree-refs' +import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' -// Why: v5 keys Codex ownership on raw token_count identity without session id -// so forks that rewrite session_meta still match. Older caches used session- -// scoped keys and can double-count after fork/resume (#8006). -const SCHEMA_VERSION = 5 +const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 @@ -467,7 +465,7 @@ export class CodexUsageStore { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanCodexUsageFiles( + const result = await codexUsageProvider.scan( createWorktreeRefs(repos, worktreesByRepo), this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] ) diff --git a/src/main/computer/macos-native-provider-paths.ts b/src/main/computer/macos-native-provider-paths.ts index 7ca8134f03f..bf0e9f3fc51 100644 --- a/src/main/computer/macos-native-provider-paths.ts +++ b/src/main/computer/macos-native-provider-paths.ts @@ -25,20 +25,3 @@ export function resolveMacOSComputerUseExecutablePath(): string | null { const executablePath = join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos') return existsSync(executablePath) ? executablePath : null } - -export function resolveMacOSNativeProviderPath(): string | null { - const override = process.env.ORCA_COMPUTER_MACOS_PROVIDER_PATH - if (override && existsSync(override)) { - return override - } - - const packaged = [join(process.resourcesPath ?? '', 'computer-use-macos/orca-computer-use-macos')] - const dev = [ - join(process.cwd(), 'native/computer-use-macos/.build/debug/orca-computer-use-macos'), - join(process.cwd(), 'native/computer-use-macos/.build/release/orca-computer-use-macos'), - resolve(__dirname, '../../native/computer-use-macos/.build/debug/orca-computer-use-macos') - ] - const candidates = process.resourcesPath ? [...packaged, ...dev] : dev - - return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null -} diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 9ce9172f8fb..7b5f3b90dda 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -26,8 +26,8 @@ import { buildWindowsHookStdinDrainEpilogue } from '../agent-hooks/hook-stdin-contract' -// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection. -// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache. +// Subscribe only to Cursor hooks needed for spinner and turn detection. +// Exclude process-boundary session hooks, which can reset the submitted-turn prompt cache. const CURSOR_EVENTS = [ 'beforeSubmitPrompt', 'stop', @@ -62,7 +62,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '@echo off', 'setlocal', - // Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts). + // Why: source current endpoint coordinates for PTYs surviving an Orca restart. 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), buildWindowsAgentHookPostCommand('cursor'), @@ -75,15 +75,15 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { return [ '#!/bin/sh', ...buildPosixHookPayloadCapture(), - // Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). + // Why: refresh endpoint coordinates so surviving PTYs keep reporting. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', 'fi', - // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead. - // Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives). + // Why: post form fields because path-bearing worktree IDs are unsafe in hand-built JSON. + // Why: pipe payload to curl stdin to keep large output off the command line. 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\', ' --connect-timeout 0.5 --max-time 1.5 \\', ' -H "Content-Type: application/x-www-form-urlencoded" \\', @@ -167,7 +167,7 @@ export class CursorHookService { const nextHooks = { ...config.hooks } const managedEvents = new Set(CURSOR_EVENTS) - // Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path. + // Why: match filenames to remove stale hooks from prior builds and user-data paths. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) // Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks. @@ -192,7 +192,7 @@ export class CursorHookService { for (const eventName of CURSOR_EVENTS) { const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] - // Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry. + // Sweep Claude- and Cursor-shaped variants so installs converge on one entry. const cleaned = removeManagedCommands(current, isManagedCommand).filter( (definition) => !isManagedCommand(definition.command as string | undefined) ) @@ -201,7 +201,7 @@ export class CursorHookService { nextHooks[eventName] = [...cleaned, definition] } - // Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value. + // Why: Cursor requires `version: 1`; preserve user-pinned values. const nextConfig: Record = { ...config, hooks: nextHooks } if (nextConfig.version === undefined) { nextConfig.version = 1 @@ -247,7 +247,7 @@ export class CursorHookService { } // Why: script-then-config order so a partial mid-install leaves a working script nothing points at. - // Why: SSH remotes always use POSIX `.sh` hook paths even when Orca runs on Windows; never derive from local OS. + // Why: SSH hooks always use POSIX .sh paths, regardless of the local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) diff --git a/src/main/daemon/terminal-private-mode-tracker.ts b/src/main/daemon/terminal-private-mode-tracker.ts deleted file mode 100644 index a8f20ef7a4d..00000000000 --- a/src/main/daemon/terminal-private-mode-tracker.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { TerminalModes } from './types' - -// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. -// Keep parser state far beyond normal mode lists while still bounding memory. -const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 - -type MouseTrackingMode = NonNullable - -// Why: @xterm/headless doesn't expose DECSET mouse/SGR encoding state, so the -// emulator mirrors it by scanning the raw byte stream; snapshots restore these -// modes on rehydrate. -export class TerminalPrivateModeTracker { - private scanTail = '' - mouseTrackingMode: MouseTrackingMode = 'none' - sgrMouseMode = false - sgrMousePixelsMode = false - - scan(data: string): void { - const input = this.scanTail + data - this.scanTail = this.extractScanTail(input) - // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars - const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g - let match: RegExpExecArray | null - while ((match = privateModeRe.exec(input)) !== null) { - if (match[0] === '\x1bc') { - this.mouseTrackingMode = 'none' - this.sgrMouseMode = false - this.sgrMousePixelsMode = false - continue - } - const params = match[1] ?? match[3] - const enabled = (match[2] ?? match[4]) === 'h' - for (const rawParam of params.split(';')) { - if (rawParam === '') { - continue - } - const param = Number(rawParam) - if (!Number.isInteger(param)) { - continue - } - if (param === 9) { - this.mouseTrackingMode = enabled ? 'x10' : 'none' - } - if (param === 1000) { - this.mouseTrackingMode = enabled ? 'vt200' : 'none' - } - if (param === 1002) { - this.mouseTrackingMode = enabled ? 'drag' : 'none' - } - if (param === 1003) { - this.mouseTrackingMode = enabled ? 'any' : 'none' - } - if (param === 1006) { - this.sgrMouseMode = enabled - this.sgrMousePixelsMode = false - } - if (param === 1016) { - this.sgrMouseMode = false - this.sgrMousePixelsMode = enabled - } - } - } - } - - private extractScanTail(input: string): string { - const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) - if (start === -1) { - return '' - } - const tail = input.slice(start) - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { - return '' - } - if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { - return tail - } - if (tail.startsWith('\x1b[?')) { - return this.isIncompleteParams(tail.slice(3)) ? tail : '' - } - if (tail.startsWith('\x9b?')) { - return this.isIncompleteParams(tail.slice(2)) ? tail : '' - } - return '' - } - - private isIncompleteParams(params: string): boolean { - return /^[0-9;]*$/.test(params) - } -} diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index ac57e7d465d..9de0ec28109 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,7 +4,7 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs' -import { open, readdir, rename, rm } from 'node:fs/promises' +import { open, readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' /** @@ -103,11 +103,13 @@ export function durableWriteTempPath(finalPath: string): string { /** * Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would - * otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save, - * the trade already accepted for rename-based atomicity. This process's own temps are skipped: a - * `..*.tmp` seen during a sweep is a live write, and deleting it fails its rename. + * otherwise accumulate forever. Callers can require a minimum age to spare another live instance's + * write. This process's own temps are always skipped because deleting one would fail its rename. */ -export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise { +export async function removeStaleDurableWriteTempFiles( + finalPath: string, + options: { minimumAgeMs?: number } = {} +): Promise { const directory = dirname(finalPath) const prefix = `${basename(finalPath)}.` const ownPrefix = `${prefix}${process.pid}.` @@ -118,7 +120,16 @@ export async function removeStaleDurableWriteTempFiles(finalPath: string): Promi .filter( (name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix) ) - .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) + .map(async (name) => { + const path = join(directory, name) + if (options.minimumAgeMs) { + const info = await stat(path).catch(() => null) + if (!info || Date.now() - info.mtimeMs < options.minimumAgeMs) { + return + } + } + await rm(path, { force: true }).catch(() => {}) + }) ) } catch { // Directory missing or unreadable — nothing to sweep. diff --git a/src/main/editor-launcher-name.ts b/src/main/editor-launcher-name.ts new file mode 100644 index 00000000000..b88511dfea3 --- /dev/null +++ b/src/main/editor-launcher-name.ts @@ -0,0 +1,44 @@ +import { basename, win32 } from 'node:path' + +export function stripMatchingQuotes(value: string): string { + const trimmed = value.trim() + const quote = trimmed[0] + if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { + return trimmed.slice(1, -1) + } + return trimmed +} + +export function hasMatchingOuterQuotes(value: string): boolean { + const trimmed = value.trim() + const quote = trimmed[0] + return (quote === '"' || quote === "'") && trimmed.endsWith(quote) +} + +function getLeadingShellCommandToken(command: string): string { + const trimmed = command.trim() + const quote = trimmed[0] + if (quote === '"' || quote === "'") { + const closingIndex = trimmed.indexOf(quote, 1) + if (closingIndex > 0) { + return trimmed.slice(1, closingIndex) + } + } + return trimmed.split(/\s+/, 1)[0] ?? '' +} + +/** Lowercased launcher name without directory or Windows executable extension. */ +export function getLauncherBaseName( + command: string, + options: { shellCommand?: boolean } = {} +): string { + const normalized = options.shellCommand + ? getLeadingShellCommandToken(command) + : stripMatchingQuotes(command) + const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized) + return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase() +} + +export function isWindowsBatchLauncher(command: string): boolean { + return /\.(?:cmd|bat)$/i.test(stripMatchingQuotes(command)) +} diff --git a/src/main/emulator/android/android-input-mapping.ts b/src/main/emulator/android/android-input-mapping.ts index 4805800f91d..d068e450e47 100644 --- a/src/main/emulator/android/android-input-mapping.ts +++ b/src/main/emulator/android/android-input-mapping.ts @@ -29,14 +29,6 @@ export function normalizedToDevicePixels( } } -export type AndroidHardwareButton = - | 'home' - | 'back' - | 'recents' - | 'power' - | 'volume_up' - | 'volume_down' - // Android KeyEvent KEYCODE_* values, including the common aliases agents use. const BUTTON_KEYCODES: Record = { home: 3, diff --git a/src/main/emulator/emulator-types.ts b/src/main/emulator/emulator-types.ts index d7bc31a26c5..f675d11bc66 100644 --- a/src/main/emulator/emulator-types.ts +++ b/src/main/emulator/emulator-types.ts @@ -14,9 +14,3 @@ export type EmulatorSessionInfo = { streamCodec?: EmulatorStreamCodec backend?: EmulatorBackendKind } - -export type EmulatorCliTarget = { - worktreeId?: string - deviceUdid?: string - emulatorId?: string // Orca-generated id from list (for stability, like browserPageId) -} diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 5422267f6e1..638e1ff02f7 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -32,6 +32,168 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => { + const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'idea' ? `${installBin}\\idea.cmd` : command + ) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\idea64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\idea64.exe`, + spawnArgs: ['C:\\workspaces\\orca'] + }) + // Why: a bare PATH `idea64` may belong to a different, stale install. + expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' }) + expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything()) + }) + + it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => { + const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'goland64' ? `${installBin}\\goland64.cmd` : command + ) + + expect( + resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\goland64.exe` + }).spawnCmd + ).toBe(`${installBin}\\goland64.exe`) + }) + + it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => { + const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + resolveCliCommandMock.mockImplementation((command: string) => + command === 'idea' ? toolboxShim : command + ) + + expect( + resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + detachedGui: true, + spawnCmd: toolboxShim, + spawnArgs: ['C:\\workspaces\\orca'] + }) + }) + + it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => { + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + detachedGui: true, + spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat', + spawnArgs: ['C:\\ws'] + }) + }) + + it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\webstorm64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => { + const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin' + expect( + resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', { + platform: 'win32', + fileExists: (candidate) => candidate === `${installBin}\\idea64.exe` + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: `${installBin}\\idea64.exe`, + spawnArgs: ['C:\\ws'] + }) + }) + + it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => { + const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe' + expect( + resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: guiExe, + spawnArgs: ['C:\\ws'] + }) + }) + + it('does not rewrite non-JetBrains .exe launchers via colocation', () => { + const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe' + expect( + resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => true + }).spawnCmd + ).toBe(codeExe) + }) + + it('keeps idea.exe when no colocated idea64.exe exists', () => { + const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe' + expect( + resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', { + platform: 'win32', + fileExists: () => false + }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: consoleExe, + spawnArgs: ['C:\\ws'] + }) + }) + + // Why: `start` re-parses argv, so only JetBrains shims may take that path. + it.each([ + ['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']], + [ + 'cursor', + 'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd', + ['--new-window', 'C:\\workspaces\\orca'] + ] + ])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => { + resolveCliCommandMock.mockImplementation(() => resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' }) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs + }) + }) + it('appends escaped paths to compound macOS open commands', () => { expect( resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", { @@ -85,6 +247,19 @@ describe('resolveExternalEditorLaunchSpec', () => { }) }) + it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => { + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', { + platform: 'win32' + }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', 'code --reuse-window C:\\workspaces\\orca'] + }) + }) + it('quotes Windows paths with spaces in compound commands', () => { expect( resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' }) @@ -245,7 +420,7 @@ describe('resolveExternalEditorLaunchSpec', () => { ).toEqual([pathValue]) }) - it('does not rewrite compound VS Code commands', () => { + it('does not rewrite compound VS Code commands into WSL remote args', () => { const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' expect( diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index a0283b18f46..6fa0e867508 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,26 +1,46 @@ +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { basename, posix, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import { parseWslUncPath } from '../shared/wsl-paths' import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher' import { resolveCliCommand } from './codex-cli/command' -import { getCmdExePath } from './win32-utils' +import { + getLauncherBaseName, + hasMatchingOuterQuotes, + stripMatchingQuotes +} from './editor-launcher-name' +import { + isJetBrainsConsoleShim, + resolveColocatedJetBrainsGuiExecutable +} from './jetbrains-windows-gui-launchers' +import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +export type ExternalEditorExecutableLaunchSpec = { + kind: 'executable' + hideWindowsConsole: boolean + /** + * Set only for Windows batch shims that would otherwise leave a Command + * Prompt behind; routes the spawn through `start` with an empty title and + * `/B`. Left unset elsewhere because `start` re-parses quoted argv. + */ + detachedGui?: boolean + spawnCmd: string + spawnArgs: string[] +} + +export type ExternalEditorShellLaunchSpec = { + kind: 'shell' + hideWindowsConsole: boolean + spawnCmd: string + spawnArgs: string[] +} + export type ExternalEditorLaunchSpec = - | { - kind: 'executable' - hideWindowsConsole: boolean - spawnCmd: string - spawnArgs: string[] - } - | { - kind: 'shell' - hideWindowsConsole: boolean - spawnCmd: string - spawnArgs: string[] - } + | ExternalEditorExecutableLaunchSpec + | ExternalEditorShellLaunchSpec function escapePosixPathForShell(pathValue: string): string { if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) { @@ -39,41 +59,6 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin : escapePosixPathForShell(pathValue) } -function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string { - const normalized = options.shellCommand - ? getLeadingShellCommandToken(command) - : stripMatchingQuotes(command) - const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized) - return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase() -} - -function getLeadingShellCommandToken(command: string): string { - const trimmed = command.trim() - const quote = trimmed[0] - if (quote === '"' || quote === "'") { - const closingIndex = trimmed.indexOf(quote, 1) - if (closingIndex > 0) { - return trimmed.slice(1, closingIndex) - } - } - return trimmed.split(/\s+/, 1)[0] ?? '' -} - -function stripMatchingQuotes(value: string): string { - const trimmed = value.trim() - const quote = trimmed[0] - if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) { - return trimmed.slice(1, -1) - } - return trimmed -} - -function hasMatchingOuterQuotes(value: string): boolean { - const trimmed = value.trim() - const quote = trimmed[0] - return (quote === '"' || quote === "'") && trimmed.endsWith(quote) -} - function isWindowsExecutablePath(command: string): boolean { return win32.isAbsolute(command) && /\.(?:cmd|exe|bat|com)$/i.test(command) } @@ -133,6 +118,46 @@ function isCompoundShellCommand(command: string): boolean { return /\s/.test(command) } +function preferJetBrainsGuiExecutable( + editorCommand: string, + platform: NodeJS.Platform, + fileExists: (path: string) => boolean +): string { + if (platform !== 'win32') { + return editorCommand + } + return resolveColocatedJetBrainsGuiExecutable(editorCommand, fileExists) ?? editorCommand +} + +function resolveSimpleEditorCommand( + command: string, + platform: NodeJS.Platform, + fileExists: (path: string) => boolean +): string { + return preferJetBrainsGuiExecutable( + resolveCliCommand(command, { platform }), + platform, + fileExists + ) +} + +function buildExecutableLaunchSpec( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): ExternalEditorExecutableLaunchSpec { + const spec: ExternalEditorExecutableLaunchSpec = { + kind: 'executable', + hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), + spawnCmd: editorCommand, + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) + } + if (spec.hideWindowsConsole && isJetBrainsConsoleShim(editorCommand, platform)) { + spec.detachedGui = true + } + return spec +} + function buildShellLaunchSpec( command: string, pathValue: string, @@ -140,6 +165,11 @@ function buildShellLaunchSpec( ): ExternalEditorLaunchSpec { const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}` if (platform === 'win32') { + // Why: no `start` wrap here. `start` re-parses the line — it swallows the + // first quoted operand as a window title and mangles remote paths with + // spaces — and on a batch target it leaves a resident nested `cmd /K`. + // User-authored compound commands run verbatim; users who want a detached + // launch already write `start` themselves. return { kind: 'shell', hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }), @@ -165,26 +195,24 @@ export function resolveExternalEditorLaunchSpec( const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND if (isDirectExecutablePath(trimmed, platform, fileExists)) { - const editorCommand = stripMatchingQuotes(trimmed) - return { - kind: 'executable', - hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), - spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) - } + // Why: settings often store a full path to idea.cmd / idea.exe; upgrade + // those the same way as PATH-resolved names when *64.exe is colocated. + return buildExecutableLaunchSpec( + preferJetBrainsGuiExecutable(stripMatchingQuotes(trimmed), platform, fileExists), + pathValue, + platform + ) } if (isCompoundShellCommand(trimmed)) { return buildShellLaunchSpec(trimmed, pathValue, platform) } - const editorCommand = resolveCliCommand(trimmed, { platform }) - return { - kind: 'executable', - hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), - spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) - } + return buildExecutableLaunchSpec( + resolveSimpleEditorCommand(trimmed, platform, fileExists), + pathValue, + platform + ) } export function resolveVsCodeRemoteSshLaunchSpec( @@ -217,3 +245,52 @@ export function resolveVsCodeRemoteSshLaunchSpec( spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue] } } + +function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): { + spawnCmd: string + spawnArgs: string[] + windowsHide: boolean +} { + // Why: only shims flagged during resolution (JetBrains) take the start /B + // detach; every other launcher keeps the waiting form so its argv survives. + if (launchSpec.kind === 'executable') { + const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, { + detachedGui: launchSpec.detachedGui === true + }) + return { ...spawned, windowsHide: launchSpec.hideWindowsConsole } + } + return { + spawnCmd: launchSpec.spawnCmd, + spawnArgs: launchSpec.spawnArgs, + windowsHide: launchSpec.hideWindowsConsole + } +} + +export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { + const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec) + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', windowsHide }) + let settled = false + function cleanup(): void { + child.off('error', onError) + child.off('spawn', onSpawn) + } + function settle(callback: () => void): void { + if (settled) { + return + } + settled = true + cleanup() + callback() + } + function onError(error: Error): void { + settle(() => rejectPromise(error)) + } + function onSpawn(): void { + child.unref() + settle(resolvePromise) + } + child.once('error', onError) + child.once('spawn', onSpawn) + }) +} diff --git a/src/main/git-bash.test.ts b/src/main/git-bash.test.ts index 5cfdc33b533..b582a7414db 100644 --- a/src/main/git-bash.test.ts +++ b/src/main/git-bash.test.ts @@ -53,10 +53,86 @@ describe('Git Bash path discovery', () => { ).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe') }) + // Why: resolveWindowsGitBashShellPath has no platform guard of its own, so passing options + // explicitly keeps these assertions meaningful on non-Windows CI hosts. it('honors an explicit bash.exe path for future user-configurable launch paths', () => { - expect(resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe')).toBe( - 'D:\\PortableGit\\bin\\bash.exe' - ) + expect( + resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe' + }) + ).toBe('D:\\PortableGit\\bin\\bash.exe') + }) + + it('rejects an explicit Git Bash path that is no longer installed', () => { + expect( + resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: () => false + }) + ).toBeNull() + }) + + it('resolves a bare bash entry through Git Bash discovery', () => { + expect( + resolveWindowsGitBashShellPath('bash', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('returns null for a bare bash entry when Git Bash is not installed', () => { + expect( + resolveWindowsGitBashShellPath('bash', { + platform: 'win32', + env: { ProgramFiles: 'C:\\Program Files' }, + exists: () => false + }) + ).toBeNull() + }) + + it('resolves an extension-less Git Bash path to the bash.exe it names', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { + platform: 'win32', + env: {}, + exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe' + }) + ).toBe('C:\\Program Files\\Git\\bin\\bash.exe') + }) + + it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', { + platform: 'win32', + env: {}, + exists: () => false + }) + ).toBeNull() + }) + + it('does not treat an extension-less non-Git bash path as Git Bash', () => { + expect( + resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() + }) + + it('does not probe a bash-prefixed path that is not bash itself', () => { + expect( + resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() }) it('recognizes Git Bash executable paths case-insensitively', () => { @@ -64,7 +140,13 @@ describe('Git Bash path discovery', () => { }) it('does not classify arbitrary bash.exe paths as Git Bash', () => { - expect(resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe')).toBeNull() + expect( + resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', { + platform: 'win32', + env: {}, + exists: () => true + }) + ).toBeNull() expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false) }) diff --git a/src/main/git-bash.ts b/src/main/git-bash.ts index 1756f132bf9..e5841e47729 100644 --- a/src/main/git-bash.ts +++ b/src/main/git-bash.ts @@ -126,13 +126,24 @@ export function resolveWindowsGitBashShellPath( return resolveGitBashPath(options) } + // Why: resolveWindowsShellStartupFamily classifies extension-less `bash` as POSIX too, so both + // spellings must resolve here or setup/PTY shell selection disagrees with the quoting family. const shellBasename = pathWin32.basename(trimmed).toLowerCase() - if (shellBasename !== 'bash.exe') { + if (shellBasename !== 'bash.exe' && shellBasename !== 'bash') { return null } if (pathWin32.isAbsolute(trimmed) || trimmed.includes('\\') || trimmed.includes('/')) { - return isGitForWindowsBashPath(trimmed) ? trimmed : null + // Why: an uninstalled/stale configured path must resolve to null like the discovery + // branch above, so setup does not commit to a bash the PTY will never spawn. + const exists = options.exists ?? existsSync + if (shellBasename === 'bash') { + // Why: Git for Windows ships only bash.exe, so an extension-less path is a request for it. + // This branch synthesizes a path the user never typed, so it must confirm the file is there. + const candidate = `${trimmed}.exe` + return isGitForWindowsBashPath(candidate) && exists(candidate) ? candidate : null + } + return isGitForWindowsBashPath(trimmed) && exists(trimmed) ? trimmed : null } return resolveGitBashPath(options) diff --git a/src/main/git/coalesced-probe.test.ts b/src/main/git/coalesced-probe.test.ts new file mode 100644 index 00000000000..2b1eb3e733a --- /dev/null +++ b/src/main/git/coalesced-probe.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve: (value: T) => void = () => {} + let reject: (error: unknown) => void = () => {} + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + return { promise, resolve, reject } +} + +describe('runCoalescedProbe', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('replaces a stale probe without letting the older cleanup remove its successor', async () => { + const probes: CoalescedProbes = new Map() + const first = deferred() + const second = deferred() + const createProbe = vi + .fn<() => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = runCoalescedProbe(probes, 'repo', createProbe, 60_000) + vi.setSystemTime(1_060_001) + const secondRequest = runCoalescedProbe(probes, 'repo', createProbe, 60_000) + + expect(createProbe).toHaveBeenCalledTimes(2) + first.resolve('old') + await expect(firstRequest).resolves.toBe('old') + + const joinedRequest = runCoalescedProbe(probes, 'repo', createProbe, 60_000) + expect(createProbe).toHaveBeenCalledTimes(2) + + second.resolve('new') + await expect(secondRequest).resolves.toBe('new') + await expect(joinedRequest).resolves.toBe('new') + expect(probes).toEqual(new Map()) + }) + + it('removes a settled probe only when it still owns the key', async () => { + const probes: CoalescedProbes = new Map() + const first = deferred() + const second = deferred() + const createProbe = vi + .fn<() => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = runCoalescedProbe(probes, 'repo', createProbe, 1) + vi.advanceTimersByTime(2) + const secondRequest = runCoalescedProbe(probes, 'repo', createProbe, 1) + + first.reject(new Error('old probe failed')) + await expect(firstRequest).rejects.toThrow('old probe failed') + expect(probes.get('repo')?.promise).toBe(second.promise) + + second.resolve('new') + await expect(secondRequest).resolves.toBe('new') + }) +}) diff --git a/src/main/git/coalesced-probe.ts b/src/main/git/coalesced-probe.ts new file mode 100644 index 00000000000..ce09ba4eaeb --- /dev/null +++ b/src/main/git/coalesced-probe.ts @@ -0,0 +1,51 @@ +/** + * Coalescing for repeated host probes (P1-D). + * + * Why: the in-flight maps under the hosted-review funnel only drop a probe when + * it settles, so one that never does pins every later retry for the life of the + * process — the outer deadline then unpins the UI without restoring any lookup + * progress. Joining a probe only while it is young enough to still be making + * progress is what lets a host recover in-session. + */ + +export type CoalescedProbe = { startedAt: number; token: object; promise: Promise } +export type CoalescedProbes = Map> + +/** Every step under a probe is bounded well inside this, so an older one is wedged, not slow. */ +export const PROBE_COALESCE_STALE_MS = 60_000 + +/** + * `createProbe` receives `ownsKey`: false once this probe has been abandoned for + * a successor, which is what tells a late one not to publish its answer over the + * fresher one someone else is already being served. + */ +export async function runCoalescedProbe( + probes: CoalescedProbes, + key: string, + createProbe: (ownsKey: () => boolean) => Promise, + staleAfterMs: number = PROBE_COALESCE_STALE_MS +): Promise { + const now = Date.now() + const existing = probes.get(key) + if (existing && now - existing.startedAt < staleAfterMs) { + return existing.promise + } + if (existing) { + // The abandoned probe keeps running; nothing here will await it again. + void existing.promise.catch(() => {}) + } + const token = {} + const entry: CoalescedProbe = { + startedAt: now, + token, + promise: createProbe(() => probes.get(key)?.token === token) + } + probes.set(key, entry) + try { + return await entry.promise + } finally { + if (probes.get(key) === entry) { + probes.delete(key) + } + } +} diff --git a/src/main/git/git-username.ts b/src/main/git/git-username.ts index 320817b7eb7..da91361d0b8 100644 --- a/src/main/git/git-username.ts +++ b/src/main/git/git-username.ts @@ -219,14 +219,6 @@ async function getGhLoginOutcome(): Promise { return probe } -/** - * Resolve the `gh` CLI's GitHub login without ever blocking the caller for - * longer than the probe wall. Never rejects; unknown resolves to ''. - */ -export async function getGhLoginAsync(): Promise { - return (await getGhLoginOutcome()).login -} - async function readGitStdout(repoPath: string, args: string[]): Promise { try { const { stdout } = await gitExecFileAsync(args, { diff --git a/src/main/git/remote-ref-probe-cache.test.ts b/src/main/git/remote-ref-probe-cache.test.ts new file mode 100644 index 00000000000..c1f6b0f0981 --- /dev/null +++ b/src/main/git/remote-ref-probe-cache.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getSshGitProviderMock, getSshGitProviderGenerationMock, gitExecFileAsyncMock } = vi.hoisted( + () => ({ + getSshGitProviderMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0), + gitExecFileAsyncMock: vi.fn() + }) +) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: getSshGitProviderGenerationMock, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable' +})) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { PROBE_COALESCE_STALE_MS } from './coalesced-probe' +import { createRemoteRefProbeCache, NEGATIVE_ENTRY_TTL_MS } from './remote-ref-probe-cache' + +/** Stands in for a forge's parser: claims one host, rejects everything else. */ +function parseExampleRef(remoteUrl: string): { repo: string } | null { + const match = remoteUrl.trim().match(/^git@example\.com:(.+?)(?:\.git)?$/) + return match ? { repo: match[1] } : null +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('remote ref probe cache (P1-D)', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + getSshGitProviderGenerationMock.mockReset() + getSshGitProviderGenerationMock.mockReturnValue(0) + gitExecFileAsyncMock.mockReset() + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('answers concurrent lookups for one repo with a single probe', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + + const answers = await Promise.all([ + cache.get('/repo', 'origin'), + cache.get('/repo', 'origin'), + cache.get('/repo', 'origin') + ]) + + expect(answers).toEqual([{ repo: 'team/repo' }, { repo: 'team/repo' }, { repo: 'team/repo' }]) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('re-probes a repo whose remotes could have changed since the miss', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + gitExecFileAsyncMock.mockRejectedValueOnce(new Error("fatal: No such remote 'origin'")) + + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + + // A remote added after the miss is only visible once the negative expires; + // nothing here watches .git/config, and SSH/WSL repos have no file to watch. + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a resolved ref without re-probing', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS * 10) + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not let a lookup on a reconnected provider join the old connection probe', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + let releaseStalled = (): void => {} + const execMock = vi + .fn() + .mockImplementationOnce( + async () => await new Promise((resolve) => (releaseStalled = () => resolve({ stdout: '' }))) + ) + .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + getSshGitProviderMock.mockReturnValue({ exec: execMock }) + + const stalled = cache.get('/repo', 'origin', 'conn-1') + getSshGitProviderGenerationMock.mockReturnValue(1) + + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) + expect(execMock).toHaveBeenCalledTimes(2) + + releaseStalled() + await expect(stalled).resolves.toBeNull() + }) + + it('does not let a probe abandoned as stale overwrite its successor answer', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + const stalled = deferred<{ stdout: string }>() + gitExecFileAsyncMock + .mockImplementationOnce(async () => await stalled.promise) + .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + + const abandoned = cache.get('/repo', 'origin') + vi.setSystemTime(1_000_000 + PROBE_COALESCE_STALE_MS + 1) + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + + // The abandoned probe still answers the caller that started it, but the repo + // state it read is older than the one already cached — it must not publish. + stalled.resolve({ stdout: 'git@elsewhere.example:team/repo.git\n' }) + await expect(abandoned).resolves.toBeNull() + + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('holds a parsed-as-not-mine remote for the negative interval, then re-asks', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@elsewhere.example:team/repo.git\n' }) + + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('never caches a miss from a runtime that could not be asked at all', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + getSshGitProviderMock.mockReturnValueOnce(undefined) + + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() + + // The provider was gone, not the remote: reconnecting must not need the TTL. + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(async () => ({ stdout: 'git@example.com:team/repo.git\n' })) + }) + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) + }) + + it('holds an SSH repo that has no such remote instead of re-asking every poll', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + const exec = vi.fn(async () => { + throw new Error("fatal: No such remote 'origin'") + }) + getSshGitProviderMock.mockReturnValue({ exec }) + + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() + expect(exec).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('keeps re-asking an SSH repo whose probe died with its transport', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + const exec = vi + .fn() + .mockRejectedValueOnce(new Error('relay request failed: connection closed')) + .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) + getSshGitProviderMock.mockReturnValue({ exec }) + + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() + await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('does not cache a probe killed on its deadline as a definitive miss', async () => { + const cache = createRemoteRefProbeCache(parseExampleRef) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('git timed out.')) + .mockResolvedValueOnce({ stdout: 'git@example.com:team/repo.git\n' }) + + await expect(cache.get('/repo', 'origin')).resolves.toBeNull() + await expect(cache.get('/repo', 'origin')).resolves.toEqual({ repo: 'team/repo' }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/git/remote-ref-probe-cache.ts b/src/main/git/remote-ref-probe-cache.ts new file mode 100644 index 00000000000..3bc2f676fbb --- /dev/null +++ b/src/main/git/remote-ref-probe-cache.ts @@ -0,0 +1,143 @@ +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' +import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe' +import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' + +/** + * The "is this repo mine?" probe every forge integration runs: read the remote's + * URL once per repo/runtime, cache what the provider's parser made of it, and + * never cache an answer a failed probe never gave. + */ + +const REPO_REF_CACHE_MAX_ENTRIES = 512 + +/** + * Why: "not this provider" only holds until someone edits the repo's remotes — + * and a repo first probed before it had any remote answers that way too. + * Nothing here watches `.git/config`, and a watcher cannot cover the SSH and WSL + * runtimes this cache also serves, so negatives expire instead: one probe per + * repo per interval is what lets a remote added mid-session be picked up without + * a restart. Positives stay, as they did before. + */ +export const NEGATIVE_ENTRY_TTL_MS = 5 * 60_000 + +type CachedRepoRef = { value: Ref | null; expiresAt: number } + +export type RemoteRefLocalGitOptions = { + wslDistro?: string +} + +export type RemoteRefProbeCache = { + get( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: RemoteRefLocalGitOptions + ): Promise + clear(): void + size(): number +} + +export function createRemoteRefProbeCache( + parseRemoteUrl: (remoteUrl: string) => Ref | null +): RemoteRefProbeCache { + const repoRefCache = new Map>() + const inFlight: CoalescedProbes = new Map() + + function remember(cacheKey: string, value: Ref | null): void { + repoRefCache.set(cacheKey, { + value, + expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY + }) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } + } + + async function probe( + cacheKey: string, + ownsKey: () => boolean, + repoPath: string, + remoteName: string, + connectionId: string | null | undefined, + localGitOptions: RemoteRefLocalGitOptions + ): Promise { + // Why: a probe abandoned as stale still runs, and its answer describes a repo + // state older than whatever the successor is about to store — or already has. + // It may still answer its own callers; it may not publish. + const publish = (value: Ref | null): void => { + if (ownsKey()) { + remember(cacheKey, value) + } + } + try { + const stdout = await readRemoteUrl( + { + repoPath, + connectionId, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }, + remoteName + ) + // Why: null is the SSH runtime being disconnected, not an answer about the + // remote — and it costs no `git`, so there is nothing here to spare. It is + // deliberately the one negative with no TTL floor: flooring it would make a + // reconnected host wait the interval out for a probe it could serve now. + if (stdout === null) { + return null + } + const result = parseRemoteUrl(stdout) + publish(result) + return result + } catch (error) { + // Why: a probe killed on its deadline says nothing about the remote, and an + // SSH failure is usually a reconnect or tunnel state rather than an answer. + // Only "no such remote" is the repo itself saying it is not this provider's + // — anything else cached would poison it, on SSH for the generation's life. + if (isTransientGitProbeError(error)) { + return null + } + if (connectionId && !isStableMissingGitRemoteError(error)) { + return null + } + publish(null) + return null + } + } + + return { + async get(repoPath, remoteName, connectionId, localGitOptions = {}) { + // Why: a reconnect retires the connection an answer came from, and with it + // the probe still running on it — stamping the generation stops a caller on + // the new connection from adopting either. + const runtimeKey = connectionId + ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` + : `local:${localGitOptions.wslDistro ?? 'host'}` + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` + const cached = repoRefCache.get(cacheKey) + if (cached) { + if (cached.expiresAt > Date.now()) { + return cached.value + } + repoRefCache.delete(cacheKey) + } + // Why: every branch of a repo resolves its forge through this probe, so a + // poll of the worktree list arrives as a burst of identical lookups. One + // young probe answers all of them instead of spawning a `git` per branch. + return runCoalescedProbe(inFlight, cacheKey, (ownsKey) => + probe(cacheKey, ownsKey, repoPath, remoteName, connectionId, localGitOptions) + ) + }, + clear() { + repoRefCache.clear() + inFlight.clear() + }, + size() { + return repoRefCache.size + } + } +} diff --git a/src/main/git/remote-url-probe.test.ts b/src/main/git/remote-url-probe.test.ts new file mode 100644 index 00000000000..8a7672cb229 --- /dev/null +++ b/src/main/git/remote-url-probe.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getSshGitProviderMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ + getSshGitProviderMock: vi.fn(), + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable' +})) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { + assertRemoteUrlReadable, + isTransientGitProbeError, + readRemoteUrl, + REMOTE_URL_PROBE_TIMEOUT_MS +} from './remote-url-probe' + +describe('remote URL probe', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + gitExecFileAsyncMock.mockReset() + }) + + it('bounds local and WSL remote reads with the shared timeout', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:acme/orca.git\n' }) + + await expect( + readRemoteUrl({ repoPath: '/repo', wslDistro: 'Ubuntu' }, 'upstream') + ).resolves.toContain('github.com') + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS, + wslDistro: 'Ubuntu' + }) + + await expect(readRemoteUrl({ repoPath: '/repo' }, 'origin')).resolves.toContain('github.com') + + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(['remote', 'get-url', 'origin'], { + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS + }) + expect(REMOTE_URL_PROBE_TIMEOUT_MS).toBe(30_000) + }) + + it('bounds the SSH remote read with the same deadline as the local one', async () => { + const exec = vi.fn( + async (_args: string[], _cwd: string, _options?: { signal?: AbortSignal }) => ({ + stdout: 'git@github.com:acme/orca.git\n' + }) + ) + getSshGitProviderMock.mockReturnValue({ exec }) + + await expect( + readRemoteUrl({ repoPath: '/repo', connectionId: 'ssh-1' }, 'origin') + ).resolves.toContain('github.com') + + const [args, cwd, options] = exec.mock.calls[0] + expect(args).toEqual(['remote', 'get-url', 'origin']) + expect(cwd).toBe('/repo') + // The relay bounds each phase separately and resets on every frame, so only a + // signal spans the whole round trip. + expect(options?.signal).toBeInstanceOf(AbortSignal) + expect(options?.signal?.aborted).toBe(false) + }) + + it('reports a probe cut off by its deadline as unavailable, not as an answer', async () => { + const aborted = new Error('Request was cancelled') + aborted.name = 'AbortError' + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(async () => { + throw aborted + }) + }) + + expect(isTransientGitProbeError(aborted)).toBe(true) + await expect( + assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) + ).rejects.toBe(aborted) + }) + + it('rethrows a timed-out local probe so callers can report unavailable', async () => { + const timeout = new Error('git timed out.') + gitExecFileAsyncMock.mockRejectedValue(timeout) + + expect(isTransientGitProbeError(timeout)).toBe(true) + await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).rejects.toBe(timeout) + }) + + it('accepts a stable missing remote as a readable, empty repository state', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error("fatal: No such remote 'origin'")) + + await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).resolves.toBeUndefined() + }) + + it('does not turn a missing SSH provider into a false readable result', async () => { + getSshGitProviderMock.mockReturnValue(null) + + await expect( + assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) + ).rejects.toThrow('SSH Git provider unavailable') + }) +}) diff --git a/src/main/git/remote-url-probe.ts b/src/main/git/remote-url-probe.ts new file mode 100644 index 00000000000..7b18c1deac9 --- /dev/null +++ b/src/main/git/remote-url-probe.ts @@ -0,0 +1,113 @@ +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-git-dispatch' +import { gitExecFileAsync } from './runner' + +/** + * The `git remote get-url` probe every forge integration runs to decide whether + * a repo is theirs (P1-D). + * + * Why: it is a local config read, so the only way it outlasts this bound is a + * wedged host — a dead network mount or stalled WSL interop. Unbounded, the call + * never returns and every caller above it hangs with it. Passing a timeout is + * also what arms the runner's kill path; Node's own waits forever on a child + * that ignores signals. The SSH branch spends the same budget as one deadline + * over the whole round trip: the relay's own bounds are per-phase and restart on + * every frame, so a relay dribbling output outlives them. + */ +export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000 + +export type RemoteUrlProbeContext = { + repoPath: string + connectionId?: string | null + wslDistro?: string +} + +/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */ +export async function readRemoteUrl( + context: RemoteUrlProbeContext, + remoteName: string +): Promise { + if (context.connectionId) { + const provider = getSshGitProvider(context.connectionId) + if (!provider) { + return null + } + const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath, { + signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS) + }) + return stdout + } + const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { + cwd: context.repoPath, + timeout: REMOTE_URL_PROBE_TIMEOUT_MS, + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + }) + return stdout +} + +const TRANSIENT_PROBE_PATTERNS = [ + /\btimed out\b/i, + /\bETIMEDOUT\b/, + /\bECONNRESET\b/, + /\bEPIPE\b/, + /connection (?:dropped|closed|refused|reset)/i +] + +/** + * A probe that was killed on its deadline, or died with its transport, says + * nothing about the remote. Callers must neither cache it as an answer nor + * report it as "no review": it is an unavailable result, not a negative one. + */ +export function isTransientGitProbeError(error: unknown): boolean { + // Why: an abort — this probe's deadline, or a caller cancelling — carries no + // message a pattern could match, but it is the emptiest answer of all. + if ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'AbortError' + ) { + return true + } + const parts: string[] = [] + if (error instanceof Error) { + parts.push(error.message) + } + if (typeof error === 'object' && error !== null) { + const execLike = error as { stderr?: unknown; code?: unknown } + if (typeof execLike.stderr === 'string') { + parts.push(execLike.stderr) + } + if (typeof execLike.code === 'string') { + parts.push(execLike.code) + } + } + if (parts.length === 0) { + parts.push(String(error)) + } + const text = parts.join('\n') + return TRANSIENT_PROBE_PATTERNS.some((pattern) => pattern.test(text)) +} + +/** + * Throws when the remote could not be read because the probe was wedged or its + * transport was gone, and returns normally for every answer — including a repo + * with no remote at all. Lets a caller that treats "nothing found" as a cacheable + * answer tell that apart from a lookup that never got to ask. + */ +export async function assertRemoteUrlReadable( + context: RemoteUrlProbeContext, + remoteName = 'origin' +): Promise { + if (context.connectionId && !getSshGitProvider(context.connectionId)) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + try { + await readRemoteUrl(context, remoteName) + } catch (error) { + if (isTransientGitProbeError(error)) { + throw error + } + } +} diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 7a9bda07fa7..db008799de9 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -574,6 +574,9 @@ branch refs/heads/main 'git rev-parse --verify --quiet refs/remotes/origin/main^{commit}': { stdout: 'base123\n' }, + 'git rev-parse --verify --quiet HEAD^{commit}': { + stdout: 'base123\n' + }, 'git merge-tree --write-tree base123 refs/heads/feature/test': { stdout: 'tree123\n' }, @@ -589,6 +592,7 @@ branch refs/heads/main expect(calls).toContain('git merge-tree --write-tree base123 refs/heads/feature/test') expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') expect(calls).toContain('git config --remove-section branch.feature/test') + expect(calls).not.toContain('git remote') }) it('deletes a squash-merged branch with branch-only merge commits via expected head', async () => { @@ -732,13 +736,15 @@ branch refs/heads/main await expect(removeWorktree('/repo', '/repo-feature')).resolves.toEqual({}) const calls = getGitCalls() + const mergeTreeCall = 'git merge-tree --write-tree base123 refs/heads/feature/test' + const mergeTreeIndexes = calls.flatMap((call, index) => (call === mergeTreeCall ? [index] : [])) + const fetchIndex = calls.indexOf('git fetch --prune origin') + const updateRefIndex = calls.indexOf('git update-ref -d refs/heads/feature/test def456') expect(calls).toContain('git fetch --prune origin') expect(calls).toContain('git update-ref -d refs/heads/feature/test def456') - expectGitCallOrder( - calls, - 'git fetch --prune origin', - 'git merge-tree --write-tree base123 refs/heads/feature/test' - ) + expect(mergeTreeIndexes).toHaveLength(1) + expect(fetchIndex).toBeLessThan(mergeTreeIndexes[0]) + expect(mergeTreeIndexes[0]).toBeLessThan(updateRefIndex) expectGitCallOrder( calls, 'git fetch --prune origin', diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index ca1b4571c17..05d218e39db 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -911,14 +911,6 @@ async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): } } -/** - * Parse `git for-each-ref --format=%(refname)%00%(refname:short)` stdout into a deduped list of short - * refs, dropping `/HEAD` pseudo-refs. Shared with the SSH branch in ipc/repos.ts so filtering can't drift. - */ -export function parseAndFilterSearchRefs(stdout: string, limit: number): string[] { - return parseAndFilterSearchRefDetails(stdout, limit).map((entry) => entry.refName) -} - export function parseAndFilterSearchRefDetails( stdout: string, limit: number, diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index b75cfa4ab5e..9ef510968b7 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -256,7 +256,7 @@ function resolveCommand( // ─── Git-specific runners ─────────────────────────────────────────── -// Why: execFile disables its cap when maxBuffer is undefined; unbounded output over V8's string max crashes main uncatchably — keep in sync with relay MAX_GIT_BUFFER. +// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER. export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024 type GitExecOptions = { diff --git a/src/main/github/stable-missing-git-remote-error.ts b/src/main/git/stable-missing-git-remote-error.ts similarity index 100% rename from src/main/github/stable-missing-git-remote-error.ts rename to src/main/git/stable-missing-git-remote-error.ts diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index f2f883f0d90..4ea08ef687b 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -2,9 +2,8 @@ import { readFile, stat } from 'node:fs/promises' import { isAbsolute, join, posix, resolve, win32 } from 'node:path' import { - branchHasNoUnmergedChangesOnAnyTarget, - getBranchCleanupTargetRefs, - refreshBranchCleanupTargetRefs + branchHasNoUnmergedChangesWithLazyTargetRefresh, + getBranchCleanupTargetRefs } from '../../shared/git-branch-cleanup' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { withSpan } from '../observability/tracer' @@ -683,10 +682,10 @@ export async function listWorktreeGraph( } } -// Why: cold start triggers many concurrent `git worktree list` spawns per repo (expensive on Windows, #7225); share the in-flight promise to collapse duplicates. +// Why: share concurrent `git worktree list` scans, which are expensive on Windows. const inFlightWorktreeScans = new Map>() -// Why: a listing after a mutation must not join a scan that predates it; bumping the generation on mutation retires older in-flight scans from sharing. +// Why: mutation generations prevent listings from joining stale scans. const worktreeScanGenerations = new Map() function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { @@ -1267,10 +1266,9 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( ...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) - await refreshBranchCleanupTargetRefs(runGit, targetRefs) // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. if ( - !(await branchHasNoUnmergedChangesOnAnyTarget( + !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( runGit, branchName, targetRefs, diff --git a/src/main/gitea/pull-request-creation.test.ts b/src/main/gitea/pull-request-creation.test.ts index 9680726c302..a8dad194408 100644 --- a/src/main/gitea/pull-request-creation.test.ts +++ b/src/main/gitea/pull-request-creation.test.ts @@ -12,7 +12,8 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: () => 0 })) vi.mock('../source-control/pull-request-template', () => ({ @@ -131,7 +132,9 @@ describe('Gitea pull request creation', () => { ok: true, number: 14 }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo') + expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index 3964d3fdaad..678dd5c6912 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -17,6 +17,7 @@ import { parseGiteaRepoRef } from './repository-ref' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Gitea repository ref parsing', () => { beforeEach(() => { @@ -121,7 +122,8 @@ describe('Gitea repository ref parsing', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -151,11 +153,13 @@ describe('Gitea repository ref parsing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { cwd: '/repo', - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -185,7 +189,9 @@ describe('Gitea repository ref parsing', () => { repo: 'project' }) - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index f5a9acc8e5f..ea12698f09b 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -1,5 +1,4 @@ -import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache' export type GiteaRepoRef = { host: string @@ -20,28 +19,16 @@ const KNOWN_NON_GITEA_HOSTS = new Set([ 'dev.azure.com', 'ssh.dev.azure.com' ]) -const REPO_REF_CACHE_MAX_ENTRIES = 512 -const repoRefCache = new Map() +const repoRefProbeCache = createRemoteRefProbeCache(parseGiteaRepoRef) /** @internal - exposed for tests only */ export function _resetGiteaRepoRefCache(): void { - repoRefCache.clear() + repoRefProbeCache.clear() } /** @internal - exposed for tests only */ export function _getGiteaRepoRefCacheSize(): number { - return repoRefCache.size -} - -function rememberRepoRefCacheEntry(cacheKey: string, value: GiteaRepoRef | null): void { - repoRefCache.set(cacheKey, value) - while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { - const oldestKey = repoRefCache.keys().next().value - if (oldestKey === undefined) { - return - } - repoRefCache.delete(oldestKey) - } + return repoRefProbeCache.size() } function decodeSegment(value: string): string { @@ -147,34 +134,7 @@ export async function getGiteaRepoRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` - if (repoRefCache.has(cacheKey)) { - return repoRefCache.get(cacheKey)! - } - try { - const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !sshGitProvider) { - return null - } - const { stdout } = sshGitProvider - ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) - : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - const result = parseGiteaRepoRef(stdout) - rememberRepoRefCacheEntry(cacheKey, result) - return result - } catch { - if (connectionId) { - // Why: SSH provider failures are often transient reconnect/tunnel states; - // caching them as "not Gitea" would poison the repo for the session. - return null - } - rememberRepoRefCacheEntry(cacheKey, null) - return null - } + return repoRefProbeCache.get(repoPath, remoteName, connectionId, localGitOptions) } export async function getGiteaRepoRef( diff --git a/src/main/github/client-work-items-query-paging.test.ts b/src/main/github/client-work-items-query-paging.test.ts index f9cf934e24e..7af6b124c34 100644 --- a/src/main/github/client-work-items-query-paging.test.ts +++ b/src/main/github/client-work-items-query-paging.test.ts @@ -63,7 +63,8 @@ vi.mock('./gh-utils', () => ({ release: releaseMock, _resetOwnerRepoCache: vi.fn(), classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr }) })) vi.mock('../git/runner', () => ({ @@ -266,6 +267,22 @@ describe('listWorkItems query paging', () => { expect(items.map((item) => item.number)).toEqual([4, 3]) }) + it('lifts a swallowed PR-side failure onto errors.prs instead of reading as end-of-data', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + // Non-availability failure (plain 403): swallowed into [], must still surface. + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403: Forbidden')) + + const envelope = await listWorkItems('/repo-root', 10, 'is:pr is:open', 2) + + expect(envelope.items).toEqual([]) + expect(envelope.errors?.issues).toBeUndefined() + expect(envelope.errors?.prs).toEqual({ + type: 'unknown', + message: expect.stringContaining('HTTP 403') + }) + }) + it('filters pull request rows out of issue Search API results', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 0226b2af42f..84119b3b2a2 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -55,7 +55,8 @@ vi.mock('./gh-utils', () => ({ release: releaseMock, _resetOwnerRepoCache: vi.fn(), classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }), - classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }) + classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }), + classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr }) })) vi.mock('../git/runner', () => ({ diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b4ede36e45..4b5f1375aa8 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -170,6 +170,7 @@ import { getPRComments, getPRForBranch, getPRForBranchOutcome, + getGitHubPRLookupRateLimitBlock, getRepoSlug, getRepoUpstream, getWorkItem, @@ -4609,3 +4610,70 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).not.toHaveBeenCalled() }) }) + +describe('getGitHubPRLookupRateLimitBlock', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getRemoteUrlForRepoMock.mockReset() + gitExecFileAsyncMock.mockReset() + getRateLimitMock.mockReset() + getRateLimitMock.mockResolvedValue(undefined) + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetOwnerRepoCache() + }) + + it('reports no block while every lookup bucket has budget', async () => { + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() + expect(getRateLimitMock).toHaveBeenCalled() + }) + + it('reports a block when either lookup bucket is exhausted', async () => { + rateLimitGuardMock.mockImplementation(((bucket: string) => + bucket === 'graphql' + ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } + : { blocked: false }) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_000_000 + }) + }) + + it('reports the latest reset when both lookup buckets are exhausted', async () => { + rateLimitGuardMock.mockImplementation(((bucket: string) => ({ + blocked: true, + remaining: 4, + limit: 5000, + // Why: core resets first, so returning it would retry into graphql's block. + resetAt: bucket === 'core' ? 1_800_000_000 : 1_800_003_600 + })) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_003_600 + }) + }) + + it('reports the later reset when graphql outlasts core', async () => { + // Retrying at the earlier reset would fail again on the bucket still blocked. + rateLimitGuardMock.mockImplementation(((bucket: string) => ({ + blocked: true, + remaining: 0, + limit: 5000, + resetAt: bucket === 'graphql' ? 1_800_000_600 : 1_800_000_000 + })) as () => RateLimitGuardResult) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ + resetAt: 1_800_000_600 + }) + }) + + it('fails open when the exempt rate-limit probe itself fails', async () => { + getRateLimitMock.mockRejectedValue(new Error('probe offline')) + + await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() + }) +}) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index ff602bc4dbe..24772ebee4b 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -55,6 +55,7 @@ import { release, classifyGhError, classifyListIssuesError, + classifyListPrsError, ghRepoExecOptions, githubRepoContext, getRemoteUrlForRepo, @@ -195,6 +196,52 @@ async function assertRateLimitBudget( } } +// Why: a branch lookup prefers REST but can fall back to `gh pr list` and +// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh +// coordinator's own estimate. +const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const + +/** + * Rate-limit floor for GitHub PR lookups that do not run through the PR refresh + * coordinator's queue (#11532). + * + * The coordinator guards and paces its own background refreshes, but + * `hostedReview:forBranch` polls the same lookup straight from the renderer. + * Ungated, the two paths together could spend the user's entire hourly quota — + * which is per user and shared with their own `gh` and CLI agents. + * Returns the reset time when the caller must not spend, else `null`. + */ +export async function getGitHubPRLookupRateLimitBlock( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ resetAt: number } | null> { + const executionOptions = ghRepoExecOptions( + githubRepoContext(repoPath, connectionId, localGitOptions) + ) + // Why: identity resolution runs local git, which can fail for reasons that + // have nothing to do with the budget; let the lookup itself classify those. + const repository = await getOriginGitHubApiRepository( + repoPath, + connectionId, + executionOptions + ).catch(() => null) + if (repository === null) { + return null + } + if (spendsSharedGitHubComQuota(repository, executionOptions)) { + // Why: the probe only warms the snapshot and is exempt from limits, so a + // failure must fail open rather than block the lookup (#7553). + await getRateLimit().catch(() => undefined) + } + // Why: retrying at the earlier reset would fail again on the bucket that has + // not reset yet, so the latest blocked reset is the only honest retry time. + const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).flatMap((guard) => (guard.blocked ? [guard.resetAt] : [])) + return resets.length > 0 ? { resetAt: Math.max(...resets) } : null +} + function prRefreshUpstreamError( err: unknown ): Extract { @@ -1076,10 +1123,11 @@ function buildWorkItemListRequest(args: { return { args: out, offset: (page - 1) * limit } } -// Why: shared shape so listWorkItems can lift the issue-side error (#1076 silent wrongness) into the IPC envelope; PR errors out of scope (§6). +// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485). type PartialWorkItemsResult = { items: MainWorkItem[] issuesError?: ClassifiedError + prsError?: ClassifiedError } function assertSshRepoHasResolvedGitHubSource(args: { @@ -1233,6 +1281,7 @@ async function listQueriedWorkItems( let successfulRequestCount = 0 let nonAvailabilityFailureCount = 0 let availabilityError: unknown + let prsError: ClassifiedError | undefined // Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6). const issueFetch = (async (): Promise => { @@ -1304,6 +1353,7 @@ async function listQueriedWorkItems( } catch (err) { console.warn('listQueriedWorkItems PRs partial failure:', err) const stderr = err instanceof Error ? err.message : String(err) + prsError = classifyListPrsError(stderr) if (classifyGitHubUnavailable(stderr)) { availabilityError ??= err } else { @@ -1320,7 +1370,8 @@ async function listQueriedWorkItems( } return { items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), - issuesError: issueResult.issuesError + issuesError: issueResult.issuesError, + prsError } } @@ -1378,7 +1429,13 @@ export async function listWorkItems( localGitOptions ) - const errors = partial.issuesError ? { issues: partial.issuesError } : undefined + const errors = + partial.issuesError || partial.prsError + ? { + ...(partial.issuesError ? { issues: partial.issuesError } : {}), + ...(partial.prsError ? { prs: partial.prsError } : {}) + } + : undefined return { items: partial.items, sources: { @@ -2956,6 +3013,13 @@ export async function getPRForBranchOutcome( if (connectionId && candidates.length === 0) { return { kind: 'no-pr', fetchedAt: Date.now() } } + // Why (#11532): account every lookup, not just the coordinator's queue — + // `hostedReview:forBranch` reaches this directly from renderer polling and + // was spending the shared quota invisibly. headRepo is `origin`, the same + // identity the coordinator guards on. + for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) { + noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions) + } let data: PullRequestLookupData | null = null let dataRepo: OwnerRepo | null = null let dataHeadRepo: OwnerRepo | null = headRepo diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index 23f3eb8b96e..fde4b6b8b84 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -32,6 +32,7 @@ const { getRateLimitMock, rateLimitGuardMock, noteRateLimitSpendMock, + noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, getSshGitProviderMock, @@ -52,6 +53,7 @@ const { blocked: false })), noteRateLimitSpendMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} @@ -116,7 +118,8 @@ vi.mock('./local-git-config-signature', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock })) import { diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index 2de37919d25..4d2453899ac 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -55,3 +55,11 @@ export function classifyListIssuesError(stderr: string): ClassifiedError { } return { type: c.type, message: readMessages[c.type] } } + +// Why: PR-side list failures need the same read-op classification — pagination +// decisions key on the type, and swallowing them made failures look like +// end-of-data (#11485). +export function classifyListPrsError(stderr: string): ClassifiedError { + const c = classifyGhError(stderr) + return { type: c.type, message: `Failed to load pull requests: ${stderr.trim()}` } +} diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index ae7b6c414e4..ca9098e53bf 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -38,6 +38,7 @@ import { __resetLocalGitConfigSignatureCacheForTests, readLocalGitConfigSignature } from './local-git-config-signature' +import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github-work-items-query-bounds' describe('github owner/repo resolution', () => { beforeEach(() => { @@ -102,7 +103,8 @@ describe('github owner/repo resolution', () => { await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) }) @@ -115,7 +117,8 @@ describe('github owner/repo resolution', () => { await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'acme', repo: 'widgets' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) }) @@ -126,7 +129,8 @@ describe('github owner/repo resolution', () => { await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) }) @@ -137,10 +141,12 @@ describe('github owner/repo resolution', () => { await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) }) @@ -176,7 +182,8 @@ describe('github owner/repo resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) await expect(getOwnerRepoForRemote('/repo', 'upstream')).resolves.toBeNull() @@ -203,7 +210,10 @@ describe('github owner/repo resolution', () => { expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2') expect(sshProvider.exec).toHaveBeenCalledWith( ['remote', 'get-url', 'origin'], - '/home/user/orca' + '/home/user/orca', + { + signal: expect.any(AbortSignal) + } ) }) @@ -245,10 +255,12 @@ describe('github owner/repo resolution', () => { // 2 runtimes x (1 upstream miss + 1 origin hit); repeat WSL call is cached. expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(4) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { cwd: '/repo', + timeout: 30_000, wslDistro: 'Ubuntu' }) }) @@ -750,7 +762,8 @@ describe('resolveIssueSource', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: 30_000 }) }) @@ -795,4 +808,17 @@ describe('gh error classification', () => { message: 'Issues are disabled on this repository.' }) }) + + // Why: the renderer detects the Search API 1000-result window by matching + // GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN against this message (#11485) — + // trimming the raw stderr out of the validation_error copy, or drifting the + // pattern off GitHub's real wording, would silently downgrade every window + // 422 to a generic failure. The stderr stays verbatim so this pins both ends. + it('keeps the search-window phrase in validation_error list messages', () => { + const stderr = + 'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)' + const classified = classifyListIssuesError(stderr) + expect(classified.type).toBe('validation_error') + expect(classified.message).toMatch(GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN) + }) }) diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index d2148edc3c1..d6770189924 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -9,7 +9,11 @@ import { extractExecError, parseRetryAfterMs } from '../git/exec-error' // WSL-aware routing. Repo-scoped callers should use the runner exports below. export const execFileAsync = promisify(execFile) export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs } -export { classifyGhError, classifyListIssuesError } from './gh-error-classification' +export { + classifyGhError, + classifyListIssuesError, + classifyListPrsError +} from './gh-error-classification' export { _getOwnerRepoCacheSize, _resetOwnerRepoCache, diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 08461f849e9..3a61b9387da 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -65,11 +65,6 @@ export function _resetOriginGitHubApiRepositoryCache(): void { originRepoInFlight.clear() } -/** @internal - exposed for cache-bound tests only */ -export function _getOriginGitHubApiRepositoryCacheSize(): number { - return originRepoCache.size -} - function pruneOriginRepoCache(now: number): void { for (const [key, entry] of originRepoCache) { if (entry.expiresAt <= now) { @@ -296,10 +291,6 @@ export async function resolveGitHubApiRepository( return null } -export function isGitHubDotComRepository(repository: GitHubApiRepository): boolean { - return isDefaultGitHubHost(repository.host) -} - // Why: the gh runner host-qualifies argv from `options.host`, so every known // host must be carried through. Pinning github.com prevents a process-level // GH_HOST from silently redirecting an otherwise unambiguous API request. diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index fd4401e3f49..89f8bf7bbd7 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -49,11 +49,6 @@ export function _resetGitHubHostAuthCache(): void { hostAuthInFlight.clear() } -/** @internal - exposed for cache-bound tests only */ -export function _getGitHubHostAuthCacheSize(): number { - return hostAuthCache.size -} - function pruneHostAuthCache(now: number): void { for (const [key, entry] of hostAuthCache) { if (entry.expiresAt <= now) { diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index f66b3dbe0d6..e348fbc6459 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -1,6 +1,7 @@ -import { gitExecFileAsync } from '../git/runner' +import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' +import { readRemoteUrl } from '../git/remote-url-probe' import type { GitHubOwnerRepo } from '../../shared/types' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { parseGitHubOwnerRepo, @@ -8,7 +9,7 @@ import { type GitHubRemoteIdentity } from './github-remote-identity-parsing' import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution' -import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' +import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' export type OwnerRepo = GitHubOwnerRepo @@ -61,7 +62,7 @@ type OwnerRepoCacheEntry = { } const ownerRepoCache = new Map() -const ownerRepoInFlight = new Map>() +const ownerRepoInFlight: CoalescedProbes = new Map() /** @internal - exposed for tests only */ export function _resetOwnerRepoCache(): void { @@ -93,19 +94,7 @@ export async function getRemoteUrlForRepo( context: GitHubRepoContext, remoteName: string ): Promise { - if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath) - return stdout - } - const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: context.repoPath, - ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) - }) - return stdout + return readRemoteUrl(context, remoteName) } function getOwnerRepoCacheTtl(value: OwnerRepo | null, configSignature?: string): number { @@ -152,22 +141,13 @@ export async function getOwnerRepoForRemote( return refreshedCached.value } - const inFlight = ownerRepoInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } - // Why: startup can resolve issue sources, PR candidates, and repo metadata - // for the same repo concurrently. Coalesce missing-remote probes. - const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature) - ownerRepoInFlight.set(cacheKey, probe) - try { - return await probe - } finally { - if (ownerRepoInFlight.get(cacheKey) === probe) { - ownerRepoInFlight.delete(cacheKey) - } - } + // for the same repo concurrently. Coalesce missing-remote probes — but only + // onto one young enough to still answer, so a wedged probe cannot pin the + // repo's identity for the life of the process (P1-D). + return runCoalescedProbe(ownerRepoInFlight, cacheKey, () => + resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature) + ) } async function resolveOwnerRepoForRemote( diff --git a/src/main/github/local-git-config-signature.ts b/src/main/github/local-git-config-signature.ts index 1a3c297d5f7..79c9da1d3bd 100644 --- a/src/main/github/local-git-config-signature.ts +++ b/src/main/github/local-git-config-signature.ts @@ -1,6 +1,7 @@ import { readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' +import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import type { GitHubRepoContext } from './github-repository-identity' type LocalGitConfigPaths = { @@ -8,7 +9,15 @@ type LocalGitConfigPaths = { worktreeConfigPath: string } -const localGitConfigSignatureInFlight = new Map>() +/** + * Why: `stat` on a dead network mount is uninterruptible, and this read runs + * *before* the bounded remote probe — unbounded, it reintroduces the hang that + * probe timeout removes (P1-D). Abandoning the read only costs the shorter + * negative-cache TTL that a missing signature already implies. + */ +const CONFIG_SIGNATURE_DEADLINE_MS = 2_000 + +const localGitConfigSignatureInFlight: CoalescedProbes = new Map() export async function readLocalGitConfigSignature( context: GitHubRepoContext @@ -18,21 +27,37 @@ export async function readLocalGitConfigSignature( // runtimes are already separated by cache key and probed through git. return undefined } - const cacheKey = context.repoPath - const inFlight = localGitConfigSignatureInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } + // Why: the deadline is caller-facing only. Bounding the coalesced probe itself + // would drop its map entry after 2s while the read runs on, so every later + // call would start another unbounded read instead of joining the one already + // out — the coalescing that keeps a wedged mount to one read (P1-D). + return withConfigSignatureDeadline( + runCoalescedProbe(localGitConfigSignatureInFlight, context.repoPath, () => + readUncachedLocalGitConfigSignature(context.repoPath) + ) + ) +} - const read = readUncachedLocalGitConfigSignature(context.repoPath) - localGitConfigSignatureInFlight.set(cacheKey, read) - try { - return await read - } finally { - if (localGitConfigSignatureInFlight.get(cacheKey) === read) { - localGitConfigSignatureInFlight.delete(cacheKey) +/** Resolves undefined rather than waiting on a read the filesystem may never finish. */ +function withConfigSignatureDeadline( + read: Promise +): Promise { + return new Promise((settle) => { + const timer = setTimeout(() => settle(undefined), CONFIG_SIGNATURE_DEADLINE_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() } - } + void read.then( + (signature) => { + clearTimeout(timer) + settle(signature) + }, + () => { + clearTimeout(timer) + settle(undefined) + } + ) + }) } export function __resetLocalGitConfigSignatureCacheForTests(): void { diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 7298c36af6d..30fc44b3c66 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -649,18 +649,9 @@ describe('pr-refresh-coordinator', () => { 'graphql', executionOptions ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - testCase.repository, - 'core', - 1, - executionOptions - ) - expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledWith( - testCase.repository, - 'graphql', - 1, - executionOptions - ) + // Why (#11532): the lookup itself debits the snapshot now, so every caller + // is accounted for; the coordinator must not double-charge on top. + expect(noteRepositoryRateLimitSpendMock).not.toHaveBeenCalled() expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 70b13ec2e69..190cc82c6f9 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -11,12 +11,11 @@ import type { import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getOriginGitHubApiRepository } from './github-api-repository' import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' import { - getRateLimit, - noteRepositoryRateLimitSpend, - repositoryRateLimitGuard, - spendsSharedGitHubComQuota -} from './rate-limit' + lookupBackoffDelayMs, + NO_REVIEW_REFRESH_INTERVAL_MS +} from '../source-control/hosted-review-refresh-pacing' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { sendToTrustedUIRenderer } from '../ipc/ui' @@ -74,8 +73,6 @@ const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 const MIN_BACKGROUND_SPACING_MS = 10_000 const BACKGROUND_BUDGET_MAX = 20 const POST_PUSH_DELAY_MS = 2_500 -const BACKOFF_BASE_MS = 60_000 -const BACKOFF_MAX_MS = 15 * 60_000 const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const ACTIVE_BURST_WINDOW_MS = 30_000 const ACTIVE_BURST_MAX = 3 @@ -424,8 +421,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres */ function nextVisibleErrorRetryAt(key: string): number { const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = - Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) + const retryAt = Date.now() + lookupBackoffDelayMs(failures) errorBackoff.set(key, { failures, retryAt }) return retryAt } @@ -509,7 +505,7 @@ function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): numbe return 30 * 60_000 } if (candidate.cachedHasPR === false) { - return 15 * 60_000 + return NO_REVIEW_REFRESH_INTERVAL_MS } if ( candidate.cachedHasPR === true && @@ -776,9 +772,8 @@ async function drainQueue(): Promise { // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. noteActiveStart(next) } - for (const bucket of buckets) { - noteRepositoryRateLimitSpend(repository, bucket, 1, executionOptions) - } + // Why (#11532): the lookup itself now debits the snapshot, so every + // caller is accounted for; debiting here too would double-count. } const outcome = await getPRForBranchOutcome( diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 2e53eff357c..db344de7133 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -54,7 +54,7 @@ import { import { githubProjectHost } from '../../shared/github-project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. -export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals' +export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' export { classifyProjectError } from './project-view/project-error-classification' export { updateProjectItemFieldValue, diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 5952118ee4c..1dd794370c7 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -82,12 +82,6 @@ export function isValidRepoSlug(value: unknown): value is string { ) } -// Backwards-compatible alias for callers that don't distinguish owner vs repo. -// Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites. -export function isValidSlug(value: unknown): value is string { - return isValidOwnerSlug(value) || isValidRepoSlug(value) -} - export function assertSlug( value: unknown, field: 'owner' | 'repo' diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 5699030a4c7..cc4ecf5fad2 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,3 +1,4 @@ +import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { glabExecFileAsync } from '../git/runner' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' @@ -8,7 +9,7 @@ export type LocalGitExecOptions = { const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 const knownHostsCacheByExecutionContext = new Map() -const knownHostsInFlightByExecutionContext = new Map>() +const knownHostsInFlightByExecutionContext: CoalescedProbes = new Map() function knownHostsExecutionKey( connectionId?: string | null, @@ -67,19 +68,11 @@ export async function getGlabKnownHosts( if (cached) { return cached } - const inFlight = knownHostsInFlightByExecutionContext.get(key) - if (inFlight) { - return inFlight - } - const probe = probeGlabKnownHosts(key, connectionId, localGitOptions) - knownHostsInFlightByExecutionContext.set(key, probe) - try { - return await probe - } finally { - if (knownHostsInFlightByExecutionContext.get(key) === probe) { - knownHostsInFlightByExecutionContext.delete(key) - } - } + // Why: only join a probe still young enough to answer, so a wedged one cannot + // pin every later retry for the life of the process (P1-D). + return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, () => + probeGlabKnownHosts(key, connectionId, localGitOptions) + ) } async function probeGlabKnownHosts( diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 218a41869c9..4b33a4c438f 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -1,6 +1,6 @@ -import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' +import { glabExecFileAsync } from '../git/runner' +import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import type { IssueSourcePreference } from '../../shared/types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { parseGlabAuthStatusHosts, @@ -83,16 +83,17 @@ async function resolveProjectRefForRemote( localGitOptions: LocalGitExecOptions ): Promise { try { - const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !sshGitProvider) { + const stdout = await readRemoteUrl( + { + repoPath, + connectionId, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }, + remoteName + ) + if (stdout === null) { return null } - const { stdout } = sshGitProvider - ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath) - : await gitExecFileAsync(['remote', 'get-url', remoteName], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { rememberProjectRefCacheEntry(cacheKey, result) @@ -112,8 +113,10 @@ async function resolveProjectRefForRemote( rememberProjectRefCacheEntry(cacheKey, remoteCandidate) return remoteCandidate } - } catch { - if (connectionId) { + } catch (error) { + // Why: a wedged or killed probe is not evidence the remote is not GitLab — + // caching it would misdetect the forge for the life of the process (P1-D). + if (connectionId || isTransientGitProbeError(error)) { return null } } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 21f483ca0f5..23e159671c6 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -27,6 +27,7 @@ import { } from './gl-utils' import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('gitlab project ref resolution', () => { beforeEach(() => { @@ -50,7 +51,8 @@ describe('gitlab project ref resolution', () => { path: 'fork/orca' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -64,7 +66,8 @@ describe('gitlab project ref resolution', () => { path: 'stablyai/orca' }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -118,11 +121,13 @@ describe('gitlab project ref resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], { cwd: '/repo', - wslDistro: 'Ubuntu' + wslDistro: 'Ubuntu', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) @@ -143,7 +148,8 @@ describe('gitlab project ref resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) await expect(getProjectRefForRemote('/repo', 'upstream')).resolves.toBeNull() @@ -159,7 +165,9 @@ describe('gitlab project ref resolution', () => { path: 'remote/orca' }) - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo') + expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { + signal: expect.any(AbortSignal) + }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -255,7 +263,8 @@ describe('resolveIssueSource', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) }) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index 32b25ab0717..a8a7b5705a0 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -1,11 +1,6 @@ -import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' -// Why: legacy generic execFile wrapper - only used by callers that don't need -// WSL-aware routing. Repo-scoped callers should use the runner exports below. -export const execFileAsync = promisify(execFile) export { glabExecFileAsync, gitExecFileAsync } export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' export { diff --git a/src/main/gitlab/project-ref-inflight.ts b/src/main/gitlab/project-ref-inflight.ts index 8df88f63a7a..a9de9d75797 100644 --- a/src/main/gitlab/project-ref-inflight.ts +++ b/src/main/gitlab/project-ref-inflight.ts @@ -1,6 +1,7 @@ +import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import type { ProjectRef } from './gl-utils' -const projectRefInFlight = new Map>() +const projectRefInFlight: CoalescedProbes = new Map() export function clearProjectRefInFlight(): void { projectRefInFlight.clear() @@ -10,17 +11,7 @@ export async function runProjectRefProbeOnce( cacheKey: string, createProbe: () => Promise ): Promise { - const inFlight = projectRefInFlight.get(cacheKey) - if (inFlight) { - return inFlight - } - const probe = createProbe() - projectRefInFlight.set(cacheKey, probe) - try { - return await probe - } finally { - if (projectRefInFlight.get(cacheKey) === probe) { - projectRefInFlight.delete(cacheKey) - } - } + // Why: joining only a probe that is still young keeps a wedged host's dead + // promise from pinning every later retry for the process lifetime (P1-D). + return runCoalescedProbe(projectRefInFlight, cacheKey, createProbe) } diff --git a/src/main/gitlab/work-item-details.test.ts b/src/main/gitlab/work-item-details.test.ts index 7af04c89b76..38e434278d8 100644 --- a/src/main/gitlab/work-item-details.test.ts +++ b/src/main/gitlab/work-item-details.test.ts @@ -32,7 +32,7 @@ vi.mock('./gl-utils', () => ({ glabRepoExecOptions: glabRepoExecOptionsMock })) -import { getWorkItemDetails } from './work-item-details' +import { countDiffLines, getWorkItemDetails } from './work-item-details' describe('getWorkItemDetails', () => { beforeEach(() => { @@ -203,3 +203,84 @@ describe('getWorkItemDetails', () => { ) }) }) + +describe('countDiffLines', () => { + it('counts added and removed lines inside a hunk', () => { + expect(countDiffLines('@@ -1 +1 @@\n-old\n+new')).toEqual({ additions: 1, deletions: 1 }) + }) + + it('counts a removed line whose original content began with `--` (SQL/Lua comment)', () => { + // Why: prefix `-` + content `-- old comment` = diff line `--- old comment`, + // which collides with the `--- a/file` header under a plain startsWith check. + expect( + countDiffLines('--- a/db.sql\n+++ b/db.sql\n@@ -1,2 +1,2 @@\n keep\n--- old comment\n+new') + ).toEqual({ additions: 1, deletions: 1 }) + }) + + it('counts an added line whose original content began with `++`', () => { + // Why: prefix `+` + content `++ flag` = diff line `+++ flag`, colliding with `+++ b/file`. + expect(countDiffLines('--- a/f.lua\n+++ b/f.lua\n@@ -1 +1 @@\n-old\n+++ flag')).toEqual({ + additions: 1, + deletions: 1 + }) + }) + + it('counts the collision on a header-less payload, the shape GitLab actually returns', () => { + // Why: the `/diffs` entity emits `json_safe_diff`, which starts at `@@` — the + // `--- a/file` form is opt-in via `unidiff=true`, which this call path never sends. + // So this, not the header-prefixed variant, is the reachable regression input. + expect(countDiffLines('@@ -1 +1 @@\n--- old comment\n+++ new comment')).toEqual({ + additions: 1, + deletions: 1 + }) + }) + + it('counts an added line of `++i;` C-style increment content', () => { + expect(countDiffLines('@@ -1 +1 @@\n-i++;\n+++i;')).toEqual({ additions: 1, deletions: 1 }) + }) + + it('yields zero for a binary-notice diff', () => { + expect(countDiffLines('Binary files a/logo.png and b/logo.png differ')).toEqual({ + additions: 0, + deletions: 0 + }) + }) + + it('skips file headers before the first hunk and yields zero for a header-only diff', () => { + expect(countDiffLines('--- a/x\n+++ b/x')).toEqual({ additions: 0, deletions: 0 }) + }) + + it('keeps additions and deletions distinct under an asymmetric hunk', () => { + expect(countDiffLines('@@ -1 +1,2 @@\n-old\n+a\n+b')).toEqual({ additions: 2, deletions: 1 }) + }) + + it('accumulates counts across multiple hunks', () => { + expect(countDiffLines('@@ -1 +1 @@\n-a\n+b\n@@ -5 +5,2 @@\n-c\n+d\n+e')).toEqual({ + additions: 3, + deletions: 2 + }) + }) + + it('yields zero for the empty diff that binary and rename-only files carry', () => { + // Why: mapMRFile passes `raw.diff ?? ''`, so binary/too_large/rename-only entries land here. + expect(countDiffLines('')).toEqual({ additions: 0, deletions: 0 }) + }) + + it('yields zero when no hunk header is present, even with +/- lines', () => { + // Why: pins the deliberate behaviour change — without a `@@` there is no hunk, so + // leading +/- can only be file headers. Counting them is what caused the collision. + expect(countDiffLines('+added\n-removed')).toEqual({ additions: 0, deletions: 0 }) + }) + + it('ignores the no-newline marker and a trailing newline', () => { + expect(countDiffLines('@@ -1 +1 @@\n-old\n+new\n\\ No newline at end of file\n')).toEqual({ + additions: 1, + deletions: 1 + }) + }) + + it('counts hunk content whose own text begins with `@@`', () => { + // Why: the `@@` hunk check runs first, so it must not swallow `+`/`-` content. + expect(countDiffLines('@@ -1 +1 @@\n-@@ old\n+@@ new')).toEqual({ additions: 1, deletions: 1 }) + }) +}) diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 066e539b815..85ddb450b17 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -169,11 +169,29 @@ async function fetchPipelineJobs( return data.map((job) => mapPipelineJob(job, pipelineId)) } -function countDiffLines(diff: string): { additions: number; deletions: number } { +/** + * Counts the added/removed lines in a single GitLab MR file's unified diff, + * feeding the +N/-N shown in the MR file list. `---`/`+++` are file headers + * only before the first `@@` hunk; every `+`/`-` line inside a hunk is content. + * Requires hunk headers: a diff with no `@@` counts zero, so do not reuse this + * for header-less agent-tool diffs (see `diffFromText` in shared/native-chat-diff). + * + * @internal - exposed for tests only. + */ +export function countDiffLines(diff: string): { additions: number; deletions: number } { let additions = 0 let deletions = 0 + // Why: `---`/`+++` are file headers only before the first hunk. A removed line + // whose original text began with `--` (SQL/Lua/Haskell `-- comment`) becomes a + // diff line `---`, colliding with the `--- a/file` header — so it must + // be counted once inside a hunk, not skipped. + let inHunk = false for (const line of diff.split('\n')) { - if (line.startsWith('+++') || line.startsWith('---')) { + if (line.startsWith('@@')) { + inHunk = true + continue + } + if (!inHunk) { continue } if (line.startsWith('+')) { diff --git a/src/main/hooks-runner.test.ts b/src/main/hooks-runner.test.ts index cd226bf9279..79827207aba 100644 --- a/src/main/hooks-runner.test.ts +++ b/src/main/hooks-runner.test.ts @@ -57,13 +57,15 @@ describe('createSetupRunnerScript', () => { ORCA_ROOT_PATH: '/test/repo', ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\', ORCA_WORKSPACE_NAME: 'feature' - }) + }), + // Why: native Windows worktrees without a configured setup shell keep the cmd runner. + shell: { family: 'cmd' } }) expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd', [ '@echo off', - 'setlocal EnableExtensions', + 'setlocal EnableExtensions DisableDelayedExpansion', 'call pnpm install', 'if errorlevel 1 exit /b %errorlevel%', 'call pnpm build', @@ -80,6 +82,110 @@ describe('createSetupRunnerScript', () => { } }) + it('converts path env vars to MSYS form for a native Windows Git Bash runner', async () => { + const fs = await import('node:fs') + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, + 'C:\\repo\\feature', + 'pnpm install', + undefined, + { family: 'posix' } + ) + + expect(result).toEqual({ + runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: '/c/Users/jinwo/git/orca', + ORCA_WORKTREE_PATH: '/c/repo/feature', + CONDUCTOR_ROOT_PATH: '/c/Users/jinwo/git/orca', + GHOSTX_ROOT_PATH: '/c/Users/jinwo/git/orca', + // Why: a display name, never a path — it must survive the conversion untouched. + ORCA_WORKSPACE_NAME: 'feature' + }), + shell: { family: 'posix' } + }) + expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith( + 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + // Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit. + expect(vi.mocked(fs.chmodSync)).not.toHaveBeenCalledWith( + 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh', + 0o755 + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('leaves non-path setup env values alone under a Git Bash runner', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } = + await import('../shared/terminal-git-credential-guard') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo\\feature', + 'pnpm install', + undefined, + { family: 'posix' } + ) + + expect(result.envVars[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard') + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps native Windows env vars in Windows form for the default cmd runner', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + { ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' }, + 'C:\\repo\\feature', + 'pnpm install' + ) + + expect(result.envVars).toEqual( + expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\Users\\jinwo\\git\\orca', + ORCA_WORKTREE_PATH: 'C:\\repo\\feature' + }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves exclamation marks in Windows runner script lines', async () => { + const { buildWindowsRunnerScript } = await import('./hooks') + + const runner = buildWindowsRunnerScript('echo hello!world!') + + // Why: launchers invoke the runner under cmd /v:on, so the runner must disable delayed + // expansion itself or `!world!` is consumed as a variable reference. + expect(runner).toContain('setlocal EnableExtensions DisableDelayedExpansion') + expect(runner).toContain('call echo hello!world!') + }) + it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => { const originalPlatform = process.platform @@ -256,4 +362,55 @@ describe('createIssueCommandRunnerScript', () => { }) } }) + + it('carries the WSL launch shell for a Windows-drive worktree routed through WSL', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('/mnt/c/repo/.git/orca/issue-command-runner.sh') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo\\feature', + 'codex exec "long command"', + { wslDistro: 'Ubuntu' } + ) + + // Why: the runner path is written back in native Windows form, so the launch needs /mnt again. + expect(result.runnerScriptPath).toBe('C:\\repo\\.git\\orca\\issue-command-runner.sh') + expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + + it('keeps native Windows issue runners on the cmd launch shell', async () => { + const originalPlatform = process.platform + + execFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript(makeRepo(), 'C:\\repo\\feature', 'pnpm install') + + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) }) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 6083638575d..b51a38e3321 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -537,7 +537,9 @@ describe('runner script builders', () => { try { const result = buildWindowsRunnerScript(script) - expect(result.startsWith('@echo off\r\nsetlocal EnableExtensions\r\n')).toBe(true) + expect( + result.startsWith('@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n') + ).toBe(true) expect(result).toContain('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%') expect(result).toContain('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%') const usedLineSplit = splitSpy.mock.calls.some( @@ -1175,6 +1177,7 @@ describe('runHook', () => { } ) expect(result.runnerScriptPath).toContain('setup-runner.sh') + expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' }) expect(mkdirSyncMock).toHaveBeenCalled() expect(writeFileSyncMock).toHaveBeenCalledWith( expect.stringContaining('setup-runner.sh'), @@ -1252,6 +1255,116 @@ describe('createSetupRunnerScript', () => { } }) as unknown as Repo + it('writes POSIX setup runners for Git Bash on native Windows paths', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + const chmodSyncMock = vi.mocked(fs.chmodSync) + writeFileSyncMock.mockClear() + chmodSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'pnpm install\r\nnpm run build', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\nnpm run build\n', + 'utf-8' + ) + expect(chmodSyncMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' } + }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves cmd.exe setup runner semantics for configured cmd users', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'pnpm install\nnpm run build', + undefined, + { family: 'cmd' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%'), + 'utf-8' + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\setup-runner.cmd', + expect.stringContaining('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%'), + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps POSIX runner behavior on POSIX platforms', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + const chmodSyncMock = vi.mocked(fs.chmodSync) + writeFileSyncMock.mockClear() + chmodSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + const { createSetupRunnerScript } = await import('./hooks') + const result = createSetupRunnerScript(makeRepo(), '/test/worktree', 'pnpm install') + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.sh'], + { cwd: '/test/worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + '/test/repo/.git/orca/setup-runner.sh', + '#!/usr/bin/env bash\nset -e\npnpm install\n', + 'utf-8' + ) + expect(chmodSyncMock).toHaveBeenCalledWith('/test/repo/.git/orca/setup-runner.sh', 0o755) + expect(result.shell).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => { gitExecFileSyncMock.mockReset() gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n') @@ -1285,6 +1398,199 @@ describe('createSetupRunnerScript', () => { }) }) +describe('createIssueCommandRunnerScript', () => { + const makeRepo = () => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } } + }) as unknown as Repo + + it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n') + const fs = await import('node:fs') + const writeFileSyncMock = vi.mocked(fs.writeFileSync) + writeFileSyncMock.mockClear() + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'gh issue view 42', + undefined, + { family: 'posix' } + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.sh'], + { cwd: 'C:\\repo-worktree' } + ) + expect(writeFileSyncMock).toHaveBeenCalledWith( + 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + '#!/usr/bin/env bash\nset -e\ngh issue view 42\n', + 'utf-8' + ) + expect(result.shell).toEqual({ family: 'posix' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the cmd issue-command runner when no setup shell is resolved', async () => { + gitExecFileSyncMock.mockReset() + gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n') + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + try { + const { createIssueCommandRunnerScript } = await import('./hooks') + const result = createIssueCommandRunnerScript( + makeRepo(), + 'C:\\repo-worktree', + 'gh issue view 42' + ) + + expect(gitExecFileSyncMock).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'], + { cwd: 'C:\\repo-worktree' } + ) + expect(result.shell).toEqual({ family: 'cmd' }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) +}) + +describe('resolveSetupRunnerShell', () => { + const installedGitBash = { + resolveGitBashShellPath: () => 'C:\\Program Files\\Git\\bin\\bash.exe' + } + + it('maps git-bash to POSIX setup launch metadata on Windows', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', installedGitBash) + ).toEqual({ + family: 'posix' + }) + }) + + it('falls back to the cmd runner when the git-bash setting has no installed Git Bash', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('keeps the cmd runner for a non-Git bash such as Cygwin', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'C:\\cygwin64\\bin\\bash.exe' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('keeps the cmd runner for a bare bash whose flavor cannot be resolved', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', { + resolveGitBashShellPath: () => null + }) + ).toEqual({ family: 'cmd' }) + }) + + it('uses the POSIX runner for a bare bash that resolves to Git Bash', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', installedGitBash) + ).toEqual({ family: 'posix' }) + }) + + it('uses the POSIX runner for an extension-less Git Bash path', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash' }, + 'win32', + installedGitBash + ) + ).toEqual({ family: 'posix' }) + }) + + it('preserves the existing cmd runner for PowerShell terminals', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell( + { + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'pwsh.exe' + }, + 'win32', + installedGitBash + ) + ).toEqual({ family: 'cmd' }) + }) + + it('preserves cmd setup compatibility when a Windows-host project has a WSL shell setting', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + + expect( + resolveSetupRunnerShell({ terminalWindowsShell: 'wsl.exe' }, 'win32', installedGitBash) + ).toEqual({ family: 'cmd' }) + }) + + it('classifies an installed explicit Git Bash executable as a POSIX runner', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + const { resolveWindowsGitBashShellPath } = await import('./git-bash') + + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' }, + 'win32', + { + resolveGitBashShellPath: (shell) => + resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => true }) + } + ) + ).toEqual({ family: 'posix' }) + }) + + it('falls back to the cmd runner when the explicit Git Bash path no longer exists', async () => { + const { resolveSetupRunnerShell } = await import('./hooks') + const { resolveWindowsGitBashShellPath } = await import('./git-bash') + + // Regression: a stale configured path used to commit setup to a .sh runner the + // PTY could never spawn, hanging wait-for-setup until the 2h timeout. + expect( + resolveSetupRunnerShell( + { terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' }, + 'win32', + { + resolveGitBashShellPath: (shell) => + resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => false }) + } + ) + ).toEqual({ family: 'cmd' }) + }) +}) + describe('shouldRunSetupForCreate', () => { const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => ({ diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 3f8a210ccf9..ad73003778f 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -8,6 +8,9 @@ import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-po import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' import { parseOrcaYaml } from '../shared/orca-yaml' +import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import { resolveWindowsGitBashShellPath } from './git-bash' import { gitExecFileSync, promptGuardShellEnv } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env' @@ -21,6 +24,7 @@ import type { WorktreeSetupLaunch } from '../shared/types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' +import type { SetupRunnerShell } from '../shared/setup-runner-command' const HOOK_TIMEOUT = 120_000 // 2 minutes @@ -28,6 +32,8 @@ export type HookRuntimeTarget = { wslDistro?: string | null } +type SetupRunnerShellSettings = Record | undefined + function getHookShell(): string | undefined { if (process.platform === 'win32') { return process.env.ComSpec || 'cmd.exe' @@ -322,6 +328,15 @@ export function getSetupCommandSource( return null } +// Why: kept in sync with pty/wsl-orca-env.ts, which path-translates the same keys for WSL. +// ORCA_WORKSPACE_NAME is a display name and the credential-guard policy is an enum — never paths. +const SETUP_RUNNER_PATH_ENV_KEYS = [ + 'ORCA_ROOT_PATH', + 'ORCA_WORKTREE_PATH', + 'CONDUCTOR_ROOT_PATH', + 'GHOSTX_ROOT_PATH' +] as const + function getSetupEnvVars(repo: Repo, worktreePath: string): Record { return { ORCA_ROOT_PATH: repo.path, @@ -383,7 +398,9 @@ function getHookWslContext( } export function buildWindowsRunnerScript(script: string): string { - let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n' + // Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an + // inherited delayed-expansion state — without this, every `!` in a user setup line is eaten. + let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n' for (const rawLine of iterateLfScriptLines(script)) { const command = rawLine.trim() @@ -420,16 +437,20 @@ export function createSetupRunnerScript( repo: Repo, worktreePath: string, script: string, - projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, + setupShell?: SetupRunnerShell ): WorktreeSetupLaunch { - return createWorktreeRunnerScript( + return createWorktreeRunnerScript({ repo, worktreePath, script, - 'setup-runner', - getHookRuntimeTarget(projectRuntime), - shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy) - ) + runnerBaseName: 'setup-runner', + runtimeTarget: getHookRuntimeTarget(projectRuntime), + waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup( + repo.hookSettings?.setupAgentStartupPolicy + ), + setupShell + }) } export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record { @@ -469,32 +490,55 @@ export function createIssueCommandRunnerScript( repo: Repo, worktreePath: string, command: string, - projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget + projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget, + setupShell?: SetupRunnerShell ): WorktreeSetupLaunch { // Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them. - return createWorktreeRunnerScript( + return createWorktreeRunnerScript({ repo, worktreePath, - command, - 'issue-command-runner', - getHookRuntimeTarget(projectRuntime) - ) + script: command, + runnerBaseName: 'issue-command-runner', + runtimeTarget: getHookRuntimeTarget(projectRuntime), + // Why: issue commands run in the same terminal as setup, so a Git Bash setup + // runner must not be paired with a cmd issue runner in one session. + setupShell + }) } -function createWorktreeRunnerScript( - repo: Repo, - worktreePath: string, - script: string, - runnerBaseName: 'setup-runner' | 'issue-command-runner', - runtimeTarget?: HookRuntimeTarget, +function createWorktreeRunnerScript(args: { + repo: Repo + worktreePath: string + script: string + runnerBaseName: 'setup-runner' | 'issue-command-runner' + runtimeTarget?: HookRuntimeTarget waitForAgentStartup?: boolean -): WorktreeSetupLaunch { + setupShell?: SetupRunnerShell +}): WorktreeSetupLaunch { + const { + repo, + worktreePath, + script, + runnerBaseName, + runtimeTarget, + waitForAgentStartup, + setupShell + } = args const envVars = getSetupRunnerEnvVars(repo, worktreePath) // Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows. const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro) - const useWindowsFormat = process.platform === 'win32' && !wslWorktree + const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree + const runnerShell: SetupRunnerShell = nativeWindowsWorktree + ? (setupShell ?? { family: 'cmd' }) + : { family: 'posix' } + const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree + ? runnerShell + : process.platform === 'win32' && runtimeTarget?.wslDistro + ? { family: 'posix', executable: 'wsl.exe' } + : undefined // Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path. - const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh` + const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh' + const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}` let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget) // Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it. @@ -507,12 +551,14 @@ function createWorktreeRunnerScript( mkdirSync(dirname(runnerScriptPath), { recursive: true }) - if (useWindowsFormat) { + if (runnerShell.family === 'cmd') { writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8') } else { writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8') - // Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL. - chmodSync(runnerScriptPath, 0o755) + if (!nativeWindowsWorktree) { + // Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL. + chmodSync(runnerScriptPath, 0o755) + } } // Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths. @@ -520,15 +566,68 @@ function createWorktreeRunnerScript( for (const key of Object.keys(envVars)) { envVars[key] = toLinuxPath(envVars[key]) } + } else if (nativeWindowsWorktree && runnerShell.family === 'posix') { + // Why: a Git Bash runner already receives its own path as /c/..., and the shell exports HOME + // and PWD the same way, so leaving ORCA_* in C:\ form would make them the lone exception. + // Only path-valued keys convert; the workspace name and policy values are not paths. + for (const key of SETUP_RUNNER_PATH_ENV_KEYS) { + const value = envVars[key] + if (value) { + envVars[key] = nativeWindowsPathToPosixShellPath(value) + } + } } return { runnerScriptPath, envVars, + // Why: WSL git returns /mnt paths that Node converts back to C:\ for file + // writes; retain the runtime signal so launch converts them to /mnt again. + // Issue-command runners take the same resolved shell, so one session never + // mixes a bash setup runner with a cmd issue runner. + ...(launchShell ? { shell: launchShell } : {}), ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {}) } } +export function resolveSetupRunnerShell( + settings: SetupRunnerShellSettings, + platform: NodeJS.Platform = process.platform, + options: { resolveGitBashShellPath?: (shell: string) => string | null } = {} +): SetupRunnerShell | undefined { + if (platform !== 'win32') { + return undefined + } + + const terminalWindowsShell = settings?.terminalWindowsShell + const configuredShell = + typeof terminalWindowsShell === 'string' && terminalWindowsShell.trim() + ? terminalWindowsShell.trim() + : 'powershell.exe' + const shellBasename = configuredShell.replaceAll('\\', '/').split('/').pop()?.toLowerCase() + const family = resolveWindowsShellStartupFamily(configuredShell) + if (family === 'posix' && shellBasename !== 'wsl.exe' && shellBasename !== 'wsl') { + // Why: the PTY resolves Git Bash independently and falls back to PowerShell when it + // is missing, so gate the .sh runner on the same resolution. This also keeps + // non-Git bash flavors (Cygwin's /cygdrive, the System32 WSL shim's /mnt) off the + // MSYS-only /c/... form the posix runner emits. + const resolveGitBashShellPath = + options.resolveGitBashShellPath ?? + ((shell: string) => resolveWindowsGitBashShellPath(shell, { platform })) + if (resolveGitBashShellPath(configuredShell)) { + // Note: Git Bash users with batch-syntax orca.yaml setup content get a bash + // interpreter from here on. The flip is intentional and documented in + // docs/reference/windows-setup-shell.md. + return { family: 'posix' } + } + } + + // Why: existing Windows setup scripts were authored for Orca's cmd runner; + // PowerShell, wsl.exe-as-terminal, and Windows-host projects can invoke it + // without changing syntax, so they intentionally stay on the cmd runner. + return { family: 'cmd' } +} + /** * Run a named hook script in the given working directory. */ diff --git a/src/main/index.ts b/src/main/index.ts index a80bea0d965..2c94c61488c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -250,6 +250,7 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles import { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' +import { QuitTeardownStartGate } from './quit-teardown-start-gate' import { PluginService } from './plugins/plugin-service' import { PluginKillListService } from './plugins/plugin-kill-list-service' import { getPluginsDataDir } from './plugins/plugin-discovery' @@ -2934,9 +2935,24 @@ app.on('before-quit', () => { rateLimits?.stop() }) -// Why: will-quit fires twice — first pass runs sync cleanup + preventDefault to await checkpoint writes; second pass exits. +// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. let daemonDisconnectDone = false +const quitTeardownStartGate = new QuitTeardownStartGate() app.on('will-quit', (e) => { + // Why return instead of re-running teardown: the second pass is Electron re-firing after + // our own app.quit(), so every step below already ran and every durable write already + // landed. Re-entering would start a fresh unawaited write that the exit then tears down. + if (daemonDisconnectDone) { + return + } + // Why preventDefault before any work: everything below must be free to await, and a + // synchronous durable write here parks the main thread — uninterruptibly, on a stalled + // network profile mount. The teardown deadline cannot rescue that, because its timer + // lives on the same thread it would need to bound (#9447 covers the wedged-transport + // half; this covers the blocked-syscall half). + if (!quitTeardownStartGate.tryStart(e)) { + return + } // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() @@ -2949,7 +2965,7 @@ app.on('will-quit', (e) => { } // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. destroySystemTray() - // Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()). + // Why: stats.flushAsync() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()). It closes them out synchronously and only defers the write. starNag?.stop() automations?.stop() // Why: plugin hosts are forked children; dispose sends shutdown and @@ -2966,7 +2982,7 @@ app.on('will-quit', (e) => { agentHookServer.stop() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. wslHookRelayManager.disposeAll() - stats?.flush() + const statsFlush = stats?.flushAsync() ?? Promise.resolve() // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. runtime?.getAgentBrowserBridge()?.destroyAllSessions() // Why: headless offscreen browser windows are main-process owned; tear them down explicitly on quit. @@ -2975,7 +2991,7 @@ app.on('will-quit', (e) => { const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() killAllPty() const watcherShutdown = shutdownWatchersOnce() - store?.flush() + const storeFlush = store?.flushAsync() ?? Promise.resolve() // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ @@ -2984,56 +3000,56 @@ app.on('will-quit', (e) => { openCodeUsage?.flush() ]).then(() => {}) - // Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit. - if (!daemonDisconnectDone) { - e.preventDefault() - // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. - const ownedPid = process.pid - const ownedRuntimeId = runtime?.getRuntimeId() - // Why: keep inside the !daemonDisconnectDone guard so the re-fired will-quit doesn't re-run RPC.stop()/metadata-clear against the updater's replacement process. - const rpcStopAndClear = runtimeRpc - ? runtimeRpc - .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) - .then(() => { - if (ownedRuntimeId) { - // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) - } - }) - .catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - : Promise.resolve() - // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. - // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. - // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() - // Why: a wedged transport (half-open post-sleep socket) can leave one - // member unsettled forever and block app.quit() until Force Quit (#9447). - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown }, - { name: 'usage-cache', promise: usageCacheFlush } - ]) - .then((pendingTeardowns) => { - if (pendingTeardowns.length > 0) { - console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) - } - }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) - .catch(() => { - /* swallow — telemetry must never prevent app.quit() */ - }) - .then(() => { - daemonDisconnectDone = true - app.quit() - }) - } + // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. + const ownedPid = process.pid + const ownedRuntimeId = runtime?.getRuntimeId() + const rpcStopAndClear = runtimeRpc + ? runtimeRpc + .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => { + if (ownedRuntimeId) { + // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => { + console.error('[runtime] Failed to stop local RPC transport:', error) + }) + : Promise.resolve() + // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. + // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. + // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. + const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + // Why: a wedged transport (half-open post-sleep socket) can leave one + // member unsettled forever and block app.quit() until Force Quit (#9447). + // Why stats/state join here: their writes are durable but not worth hanging the app for. + // Losing at most the last debounce interval beats a quit that never completes, and the + // temp+rename swap means a write cut short by the deadline leaves the old file intact. + settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'usage-cache', promise: usageCacheFlush }, + { name: 'stats', promise: statsFlush }, + { name: 'state', promise: storeFlush } + ]) + .then((pendingTeardowns) => { + if (pendingTeardowns.length > 0) { + console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) + } + }) + .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) + .catch(() => { + /* swallow — telemetry must never prevent app.quit() */ + }) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) }) app.on('window-all-closed', () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 0e44962906e..e103b7b5cd4 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -13,11 +13,13 @@ import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-disco import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import type { + AiVaultFirstUserPromptArgs, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, @@ -286,6 +288,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo (_event, args?: AiVaultSubagentListArgs): Promise => listAiVaultSubagentSessions(args) ) + ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) => + handleAiVaultGetFirstUserPrompt(args) + ) // DOM focus/visibility events don't fire in the renderer on macOS app // activation, so refresh-on-refocus needs this main-process signal. app.on('browser-window-focus', (_event, window) => { diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 83aab9d2fab..1fb1bb12d7b 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -298,6 +298,49 @@ describe('renderer breadcrumb IPC routing', () => { ]) }) + // Why: the renderer guard is once per tab-id/verdict, so one stale worktree + // map can emit enough crumbs to evict the pre-crash trail. + it('coalesces duplicate-tab-owner notices across tabs', () => { + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: true } + }) + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 3, resolvedToActiveWorktree: true } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) { + expect(call[0]).toMatchObject({ + coalesceKey: 'terminal_tab_id_owned_by_multiple_worktrees:true' + }) + } + }) + + // Why flag-scoped: coalescing keeps only the newest payload, and the verdict + // flips under a persisting duplicate, so one would erase the other. + it('keeps a non-converging duplicate-tab-owner notice out of the converging one', () => { + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: false } + }) + emitRendererBreadcrumb({ + name: 'terminal_tab_id_owned_by_multiple_worktrees', + data: { ownerCount: 2, resolvedToActiveWorktree: true } + }) + + expect( + recordCoalescedCrashBreadcrumbMock.mock.calls.map( + (call) => (call[0] as { coalesceKey: string }).coalesceKey + ) + ).toEqual([ + 'terminal_tab_id_owned_by_multiple_worktrees:false', + 'terminal_tab_id_owned_by_multiple_worktrees:true' + ]) + }) + it('records non-error renderer breadcrumbs without coalescing', () => { emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 4cdd4bdbbb4..f028c7bc3b3 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -326,11 +326,13 @@ function buildUncapturedCrashReportText( // storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds, // erasing the pre-crash trail. Coalesce repeats into one entry that carries a // suppressed count instead. +const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', 'terminal_park_verdict_churn', 'terminal_safe_fit_retry_exhausted', + DUPLICATE_TAB_OWNER_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -362,6 +364,11 @@ function rendererBreadcrumbCoalesceKey( if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { return `${name}:${String(data?.kind ?? '')}` } + // Why: a stale map can emit once per tab-id/verdict; key by verdict so + // last-write coalescing cannot erase the other signal while remaining bounded. + if (name === DUPLICATE_TAB_OWNER_BREADCRUMB) { + return `${name}:${String(data?.resolvedToActiveWorktree ?? '')}` + } const primaryMessage = name === 'renderer_error' ? data?.message : data?.reasonMessage const fallbackMessage = name === 'renderer_error' ? data?.errorMessage : undefined const message = diff --git a/src/main/ipc/feedback-image-attachments.ts b/src/main/ipc/feedback-image-attachments.ts index f574a7129ab..361fe8b3fc0 100644 --- a/src/main/ipc/feedback-image-attachments.ts +++ b/src/main/ipc/feedback-image-attachments.ts @@ -25,10 +25,6 @@ export function isSupportedFeedbackImageContentType(contentType: string): boolea return Object.hasOwn(FEEDBACK_IMAGE_EXTENSIONS, contentType) } -export function getSupportedFeedbackImageContentTypes(): string[] { - return Object.keys(FEEDBACK_IMAGE_EXTENSIONS) -} - export function feedbackImageFilename(index: number, contentType: string): string { return `feedback-image-${index + 1}.${FEEDBACK_IMAGE_EXTENSIONS[contentType]}` } diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 2f0e3e155b2..cef5d7f1d66 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -285,6 +285,26 @@ describe('registerHostedReviewHandlers', () => { localGitExecOptions: { wslDistro: 'Ubuntu' } }) ) + // Card-list polling is the O(N) tier and must not claim the fast one. + expect(getHostedReviewForBranchMock.mock.calls[0][0]).not.toHaveProperty('active') + }) + + it('carries a selected-worktree claim through to the branch lookup', async () => { + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + branch: 'feature/selected', + active: true + }) + + // Why: the right sidebar renders only the selected worktree, so its lookup + // earns the per-minute tier instead of the card-list interval (#11532). + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ branch: 'feature/selected', active: true }) + ) }) it('passes SSH connectionId through create eligibility instead of blocking the worktree', async () => { diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 07e36469ef6..670064d3e54 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -91,6 +91,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, currentHeadOid: args.currentHeadOid ?? null, + ...(args.active === true ? { active: true } : {}), ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts index 3cad49f00ff..fe69ce2e138 100644 --- a/src/main/ipc/orca-profiles.test.ts +++ b/src/main/ipc/orca-profiles.test.ts @@ -55,12 +55,12 @@ vi.mock('../orca-profiles/profile-index-store', () => ({ setActiveOrcaProfile: setActiveOrcaProfileMock })) -function makeStoreMock(flush = vi.fn()): { - flush: typeof flush +function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): { + flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync freezeWrites: ReturnType getSettings: () => Record } { - return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) } + return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) } } vi.mock('../orca-profiles/profile-project-transfer', () => ({ @@ -162,6 +162,7 @@ describe('registerOrcaProfileHandlers', () => { expect(flush.mock.invocationCallOrder[0]).toBeLessThan( setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY ) + expect(flush).toHaveBeenCalledBefore(onBeforeRelaunch) expect(appRelaunchMock).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(150) @@ -192,6 +193,27 @@ describe('registerOrcaProfileHandlers', () => { expect(appRelaunchMock).not.toHaveBeenCalled() }) + it('does not switch profiles when persistence cannot reach quiescence', async () => { + const flush = vi.fn(() => new Promise(() => {})) + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) + + const switchProfile = Promise.resolve( + handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + ) + const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout') + await vi.advanceTimersByTimeAsync(20_000) + await rejection + + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + expect(appRelaunchMock).not.toHaveBeenCalled() + expect(onBeforeRelaunch).not.toHaveBeenCalled() + }) + it('does not relaunch when switching to the active profile', async () => { getOrcaProfileListStateMock.mockReturnValue({ activeProfileId: 'local-default', diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts index b11478bdf89..497da481ae0 100644 --- a/src/main/ipc/orca-profiles.ts +++ b/src/main/ipc/orca-profiles.ts @@ -34,6 +34,7 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' +import { flushActiveProfileBeforeFileMutation } from '../orca-profiles/profile-persistence-deadline' import { normalizeExecutionHostId } from '../../shared/execution-host' import { createCloudLinkedOrcaProfile, @@ -212,8 +213,8 @@ export function registerOrcaProfileHandlers( } // Why: the current profile must be persisted before the global index // points startup at the target profile. + await flushActiveProfileBeforeFileMutation(store) await runBeforeProfileRelaunch(options.onBeforeRelaunch) - store.flush() setActiveOrcaProfile(profileId) scheduleProfileRelaunch('profile-switch') @@ -236,10 +237,7 @@ export function registerOrcaProfileHandlers( if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { // Why: transfer before any relaunch side effect so a duplicate-target // or validation failure cannot strand the app in a quitting state. - // flush→transfer→freeze runs synchronously with no interleaving, and - // the freeze keeps late sync saves from resurrecting the moved - // project from stale memory before the relaunch. - store.flush() + await flushActiveProfileBeforeFileMutation(store) const result = transferOrcaProfileProject(args, getProfileUserDataPath()) if (result.status === 'transferred') { store.freezeWrites() @@ -250,7 +248,7 @@ export function registerOrcaProfileHandlers( } return result } - store.flush() + await flushActiveProfileBeforeFileMutation(store) return transferOrcaProfileProject(args, getProfileUserDataPath()) } ) diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index 24f1803ee68..3dad4dee73c 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -117,21 +117,6 @@ export function resolvePendingWatcherUnsubscribes( pendingUnsubscribes.clear() } -export function disposeWatcherSupervisorSubscriptions( - records: Map, - pendingUnsubscribes: Map void>, - cancelledSubscribesAwaitingChild: Set, - error: Error -): void { - for (const record of records.values()) { - resetPendingSubscribeAttempt(record) - takePendingSubscribe(record)?.reject(error) - } - resolvePendingWatcherUnsubscribes(pendingUnsubscribes) - cancelledSubscribesAwaitingChild.clear() - records.clear() -} - export type CreateHostWatcherSubscriptionOptions = { record: WatcherProcessSubscriptionRecord records: Map diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 978edb21c6b..223fed564f8 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -21,9 +21,6 @@ export type { } from './parcel-watcher-process-protocol' const sharedWatcherProcessSupervisor = new WatcherProcessSupervisor() -// Why: the canary pauses during legitimate crawls, so production callers need -// a separate liveness fuse once a child reports that it owns native setup. -export const WATCHER_PROCESS_SUBSCRIBE_TIMEOUT_MS = 60_000 // Why: healthy roots share one child; only fault quarantine scales to four, // containing a failed shard without paying that RSS cost during normal use. const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool() diff --git a/src/main/ipc/remote-watcher-event-batch.test.ts b/src/main/ipc/remote-watcher-event-batch.test.ts index b659c446aed..19e946d4007 100644 --- a/src/main/ipc/remote-watcher-event-batch.test.ts +++ b/src/main/ipc/remote-watcher-event-batch.test.ts @@ -80,19 +80,20 @@ describe('createRemoteWatcherEventBatch', () => { expect(deliver).not.toHaveBeenCalled() }) - it('clears a stale delete when a later update is known to be a file', () => { + it('keeps a replacement delete when a directory is recreated as a file', () => { const deliver = vi.fn() const batch = makeBatch(deliver) batch.push([ - { kind: 'delete', absolutePath: `${ROOT}/a.ts` }, - { kind: 'create', absolutePath: `${ROOT}/a.ts` }, - { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'create', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false } ]) vi.advanceTimersByTime(150) expect(deliver.mock.calls[0][0]).toEqual([ - { kind: 'update', absolutePath: `${ROOT}/a.ts`, isDirectory: false } + { kind: 'delete', absolutePath: `${ROOT}/dist` }, + { kind: 'update', absolutePath: `${ROOT}/dist`, isDirectory: false } ]) }) @@ -267,4 +268,39 @@ describe('createRemoteWatcherEventBatch', () => { expect(event.absolutePath).not.toMatch(/^[a-zA-Z]:/) } }) + + it('keeps NFC- and NFD-distinct remote POSIX names separate', () => { + const deliver = vi.fn() + const batch = makeBatch(deliver) + const nfcPath = `${ROOT}/caf\u00e9.txt` + const nfdPath = `${ROOT}/cafe\u0301.txt` + + batch.push([{ kind: 'update', absolutePath: nfcPath }]) + batch.push([{ kind: 'update', absolutePath: nfdPath }]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: nfcPath }, + { kind: 'update', absolutePath: nfdPath } + ]) + }) + + it('still coalesces equivalent Windows path spellings', () => { + const deliver = vi.fn() + const batch = createRemoteWatcherEventBatch({ + rootPath: 'C:\\Repo', + deliver, + trailingMs: 150, + maxWaitMs: 500, + maxEvents: 5_000 + }) + + batch.push([{ kind: 'update', absolutePath: 'C:\\Repo\\CAF\u00c9.txt' }]) + batch.push([{ kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' }]) + vi.advanceTimersByTime(150) + + expect(deliver.mock.calls[0][0]).toEqual([ + { kind: 'update', absolutePath: 'c:/repo/cafe\u0301.txt' } + ]) + }) }) diff --git a/src/main/ipc/remote-watcher-event-batch.ts b/src/main/ipc/remote-watcher-event-batch.ts index 86f10e81d96..713c6068032 100644 --- a/src/main/ipc/remote-watcher-event-batch.ts +++ b/src/main/ipc/remote-watcher-event-batch.ts @@ -1,5 +1,8 @@ import type { FsChangeEvent } from '../../shared/types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' export type RemoteWatcherEventBatchOptions = { rootPath: string @@ -14,13 +17,18 @@ export type RemoteWatcherEventBatch = { close: () => void } -// Mirrors coalesceEvents in filesystem-watcher.ts, with two deliberate divergences: a hoisted delete -// survives a later event of unknown isDirectory, so delete→create→update over-emits a delete; and -// delete→create→delete emits that delete, which local drops — the path is gone, so the renderer needs it. -function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { +// Unlike local coalescing, a recreate keeps its delete through live updates so cached dirs are purged; +// delete→create→delete also remains a net delete because the path predated the batch. +function posixEventIdentity(absolutePath: string): string { + const normalized = absolutePath.replace(/\/+/g, '/') + return normalized === '/' ? normalized : normalized.replace(/\/+$/, '') +} + +function coalesceRemoteEvents(raw: FsChangeEvent[], rootPath: string): FsChangeEvent[] { const lastByKey = new Map() const deleteBeforeCreate = new Map() const passthrough: FsChangeEvent[] = [] + const windowsPaths = isWindowsAbsolutePathLike(rootPath) for (const event of raw) { if (event.kind !== 'create' && event.kind !== 'update' && event.kind !== 'delete') { @@ -28,10 +36,10 @@ function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { passthrough.push(event) continue } - // Why: key on the comparison form, never node path.resolve — resolving a remote POSIX path on a - // Windows host mints C:\home\u\f, which the renderer's watch canonicalizer then rejects outright. - // Residual cost: its NFC fold merges canonically equivalent byte-distinct names on a POSIX host. - const key = normalizeRuntimePathForComparison(event.absolutePath) + // POSIX event identity keeps byte-distinct names; Windows still folds separators and casing. + const key = windowsPaths + ? normalizeRuntimePathForComparison(event.absolutePath) + : posixEventIdentity(event.absolutePath) const prev = lastByKey.get(key) if (prev) { @@ -51,9 +59,8 @@ function coalesceRemoteEvents(raw: FsChangeEvent[]): FsChangeEvent[] { lastByKey.set(key, event) - // Why: drop the hoisted delete only when the later event proves the path is a live file, or repeats the - // delete. Remote events carry no isDirectory, and discarding it there strands a dead dir's cached subtree. - if (event.kind === 'delete' || (event.kind === 'update' && event.isDirectory === false)) { + // A final delete subsumes the transition; a live update cannot prove the old entry was not a directory. + if (event.kind === 'delete') { deleteBeforeCreate.delete(key) } } @@ -89,7 +96,7 @@ export function createRemoteWatcherEventBatch({ deliver([{ kind: 'overflow', absolutePath: rootPath }]) return } - const coalesced = coalesceRemoteEvents(pending) + const coalesced = coalesceRemoteEvents(pending, rootPath) if (coalesced.length > 0) { deliver(coalesced) } diff --git a/src/main/ipc/renderer-shutdown-checkpoint.test.ts b/src/main/ipc/renderer-shutdown-checkpoint.test.ts index ca6e335f055..78accf8abe1 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.test.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.test.ts @@ -27,19 +27,21 @@ describe('registerRendererShutdownCheckpointHandler', () => { syncHandlers.clear() }) - it('commits every shutdown state mutation before flushing both stores', () => { + it('stages every shutdown mutation before queueing persistence', () => { const callOrder: string[] = [] const store = { - setWorkspaceSession: vi.fn((_state, hostId?: string) => { + stageWorkspaceSessionBeforeUnload: vi.fn((_state, hostId?: string) => { callOrder.push(`session:${hostId ?? 'local'}`) }), updateUI: vi.fn(() => callOrder.push('ui')), - flushOrThrow: vi.fn(() => callOrder.push('flush')), - flushActiveViewPreferenceOrThrow: vi.fn(() => callOrder.push('active-view')) + flushPendingAsync: vi.fn(() => { + callOrder.push('persist') + return Promise.resolve() + }) } registerRendererShutdownCheckpointHandler(store as never) - const handler = syncHandlers.get('app:persist-before-unload-sync') + const handler = syncHandlers.get('app:stage-before-unload-sync') expect(handler).toBeDefined() const event: { returnValue?: unknown } = {} const localSession = { activeWorktreeId: 'local-worktree' } @@ -49,76 +51,75 @@ describe('registerRendererShutdownCheckpointHandler', () => { ui: { activeView: 'settings' } }) - expect(store.setWorkspaceSession).toHaveBeenNthCalledWith(1, localSession, undefined) - expect(store.setWorkspaceSession).toHaveBeenNthCalledWith(2, remoteSession, 'runtime:host-1') + expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( + 1, + localSession, + undefined + ) + expect(store.stageWorkspaceSessionBeforeUnload).toHaveBeenNthCalledWith( + 2, + remoteSession, + 'runtime:host-1' + ) expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' }) - expect(store.flushOrThrow).toHaveBeenCalledTimes(1) - expect(store.flushActiveViewPreferenceOrThrow).toHaveBeenCalledTimes(1) - expect(callOrder).toEqual([ - 'session:local', - 'session:runtime:host-1', - 'ui', - 'flush', - 'active-view' - ]) + expect(store.flushPendingAsync).toHaveBeenCalledTimes(1) + expect(callOrder).toEqual(['session:local', 'session:runtime:host-1', 'ui', 'persist']) expect(event.returnValue).toEqual({ ok: true }) }) - it('reports a failed durable checkpoint so the renderer can retry', () => { + it('reports a staging failure so the renderer can retry', () => { const store = { - setWorkspaceSession: vi.fn(), - updateUI: vi.fn(), - flushOrThrow: vi.fn(() => { + stageWorkspaceSessionBeforeUnload: vi.fn(), + updateUI: vi.fn(() => { throw new Error('disk full') }), - flushActiveViewPreferenceOrThrow: vi.fn() + flushPendingAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) - const handler = syncHandlers.get('app:persist-before-unload-sync') + const handler = syncHandlers.get('app:stage-before-unload-sync') const event: { returnValue?: unknown } = {} handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) expect(event.returnValue).toEqual({ ok: false }) }) - it('still flushes the active-view sidecar when the durable flush throws', () => { - // Why: the two stores are independent; a durable-state failure must not drop - // the tiny active-view checkpoint (and vice versa). + it('does not queue persistence when staging is incomplete', () => { const store = { - setWorkspaceSession: vi.fn(), + stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushOrThrow: vi.fn(() => { - throw new Error('disk full') - }), - flushActiveViewPreferenceOrThrow: vi.fn() + flushPendingAsync: vi.fn(() => Promise.resolve()) } registerRendererShutdownCheckpointHandler(store as never) - const handler = syncHandlers.get('app:persist-before-unload-sync') + store.updateUI.mockImplementation(() => { + throw new Error('invalid state') + }) + const handler = syncHandlers.get('app:stage-before-unload-sync') const event: { returnValue?: unknown } = {} handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) - expect(store.flushActiveViewPreferenceOrThrow).toHaveBeenCalledTimes(1) + expect(store.flushPendingAsync).not.toHaveBeenCalled() expect(event.returnValue).toEqual({ ok: false }) }) - it('flushes the durable store even when the active-view flush throws', () => { + it('returns before the asynchronous persistence settles', () => { + let resolve!: () => void + const pending = new Promise((next) => { + resolve = next + }) const store = { - setWorkspaceSession: vi.fn(), + stageWorkspaceSessionBeforeUnload: vi.fn(), updateUI: vi.fn(), - flushOrThrow: vi.fn(), - flushActiveViewPreferenceOrThrow: vi.fn(() => { - throw new Error('disk full') - }) + flushPendingAsync: vi.fn(() => pending) } registerRendererShutdownCheckpointHandler(store as never) - const handler = syncHandlers.get('app:persist-before-unload-sync') + const handler = syncHandlers.get('app:stage-before-unload-sync') const event: { returnValue?: unknown } = {} handler?.(event, { sessions: [], ui: { activeView: 'settings' } }) - expect(store.flushOrThrow).toHaveBeenCalledTimes(1) - expect(event.returnValue).toEqual({ ok: false }) + expect(event.returnValue).toEqual({ ok: true }) + resolve() }) }) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index d268d388dd0..a1fafa16634 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -3,38 +3,27 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' import type { Store } from '../persistence' -type PersistBeforeUnloadSyncArgs = { +type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial } export function registerRendererShutdownCheckpointHandler(store: Store): void { - ipcMain.on('app:persist-before-unload-sync', (event, args: PersistBeforeUnloadSyncArgs) => { + ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => { let ok = true - // Why: apply both renderer-owned snapshots before synchronously flushing - // each owning store, so an immediate exit cannot outrun either update. try { for (const { state, hostId } of args.sessions) { - store.setWorkspaceSession(state, hostId) + store.stageWorkspaceSessionBeforeUnload(state, hostId) } store.updateUI(args.ui) } catch (error) { console.error('[app] Failed to stage renderer state before unload:', error) ok = false } - // Why: the durable snapshot and the active-view sidecar are independent stores; - // flush each on its own so one store's failure can't skip the other's checkpoint. - try { - store.flushOrThrow() - } catch (error) { - console.error('[app] Failed to flush durable state before unload:', error) - ok = false - } - try { - store.flushActiveViewPreferenceOrThrow() - } catch (error) { - console.error('[app] Failed to flush active-view preference before unload:', error) - ok = false + if (ok) { + void store.flushPendingAsync().catch((error) => { + console.error('[app] Failed to persist staged renderer state:', error) + }) } event.returnValue = { ok } }) diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 361264d917e..946a0d30cf8 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -47,15 +47,6 @@ export function closeRemoteRuntimeRequestConnection(environmentId: string): void closeRemoteRuntimeSharedControlConnection(environmentId) } -export function closeAllRemoteRuntimeRequestConnections(): void { - for (const environmentId of Array.from(requestConnections.keys())) { - closeRemoteRuntimeRequestConnection(environmentId) - } - for (const environmentId of Array.from(sharedControlConnections.keys())) { - closeRemoteRuntimeSharedControlConnection(environmentId) - } -} - export function sendRemoteRuntimeSharedControlRequest( environmentId: string, pairing: PairingOffer, diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 7193da4beaa..9671eb7a09c 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -297,9 +297,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -320,9 +324,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -334,16 +342,25 @@ describe('registerShellHandlers', () => { }) it('uses a provided launcher command', async () => { + resolveCliCommandMock.mockReturnValueOnce('custom-editor') const workspacePath = resolve('workspace') const handler = getHandler('shell:openInExternalEditor') - await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ - ok: true + await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual( + { + ok: true + } + ) + expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', { + platform: process.platform }) - expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'custom-editor', + [normalize(workspacePath)], + { + detachedGui: false + } + ) }) it.runIf(process.platform === 'win32')( @@ -357,11 +374,13 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ - '--remote', - 'wsl+Ubuntu Preview', - '/home/Ada Lovelace/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + codeShim, + ['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'], + { + detachedGui: false + } + ) } ) @@ -377,9 +396,13 @@ describe('registerShellHandlers', () => { ok: true }) expect(resolveCliCommandMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + nvimPath, + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], { detached: true, stdio: 'ignore', @@ -392,6 +415,37 @@ describe('registerShellHandlers', () => { } }) + it('detaches JetBrains batch shims on Windows but leaves other launchers waiting', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const workspacePath = normalize(resolve('workspace')) + const ideaShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd' + const codeShim = 'C:\\Tools\\code.cmd' + const handler = getHandler('shell:openInExternalEditor') + + try { + resolveCliCommandMock.mockReturnValueOnce(ideaShim) + await expect(handler({}, { path: workspacePath, command: 'idea' })).resolves.toEqual({ + ok: true + }) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(ideaShim, [workspacePath], { + detachedGui: true + }) + + resolveCliCommandMock.mockReturnValueOnce(codeShim) + await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({ + ok: true + }) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(codeShim, [workspacePath], { + detachedGui: false + }) + } finally { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + } + }) + it('forces Cursor launcher folders into a new window', async () => { resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor') const workspacePath = resolve('workspace') @@ -400,18 +454,24 @@ describe('registerShellHandlers', () => { await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/cursor', + ['--new-window', normalize(workspacePath)], + { + detachedGui: false + } + ) resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd') await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [ - '--new-window', - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith( + 'C:\\Cursor\\cursor.cmd', + ['--new-window', normalize(workspacePath)], + { + detachedGui: false + } + ) }) it('falls back to VS Code when command is blank', async () => { @@ -438,9 +498,13 @@ describe('registerShellHandlers', () => { expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, { platform: process.platform }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [ - normalize(workspacePath) - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'editor-cli', + [normalize(workspacePath)], + { + detachedGui: false + } + ) expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], { detached: true, stdio: 'ignore', @@ -510,11 +574,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder', remotePath], + { + detachedGui: false + } + ) }) it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => { @@ -535,11 +601,13 @@ describe('registerShellHandlers', () => { handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) expect(statMock).not.toHaveBeenCalled() - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [ - '--remote', - 'ssh-remote+Ada@win-builder.example.com', - remotePath - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + 'C:\\Tools\\code.cmd', + ['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath], + { + detachedGui: false + } + ) }) it('opens a manual port-22 target with a host-only authority when username is blank', async () => { @@ -558,11 +626,13 @@ describe('registerShellHandlers', () => { await expect( handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' }) ).resolves.toEqual({ ok: true }) - expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [ - '--remote', - 'ssh-remote+builder.example.com', - '/srv/project' - ]) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith( + '/usr/local/bin/code', + ['--remote', 'ssh-remote+builder.example.com', '/srv/project'], + { + detachedGui: false + } + ) }) it('rejects relative SSH paths before resolving or spawning a launcher', async () => { diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 760b32eed2a..80f02552f18 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,5 +1,4 @@ import { ipcMain, shell, dialog } from 'electron' -import { spawn } from 'node:child_process' import { constants, copyFile, readFile, stat } from 'node:fs/promises' import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,12 +9,11 @@ import type { } from '../../shared/shell-open-types' import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' import type { Store } from '../persistence' -import { getSpawnArgsForWindows } from '../win32-utils' import { EXTERNAL_EDITOR_CLI_COMMAND, + launchExternalEditor, resolveExternalEditorLaunchSpec, - resolveVsCodeRemoteSshLaunchSpec, - type ExternalEditorLaunchSpec + resolveVsCodeRemoteSshLaunchSpec } from '../external-editor-launch' import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority' @@ -72,49 +70,6 @@ async function openInFileManager( } } -async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise { - const { spawnCmd, spawnArgs } = - launchSpec.kind === 'executable' - ? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs) - : { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs } - - await new Promise((resolvePromise, rejectPromise) => { - const child = spawn(spawnCmd, spawnArgs, { - detached: true, - stdio: 'ignore', - // Why: terminal editors such as nvim need a visible console on Windows; - // GUI editor launches stay hidden to avoid command-shim flashes. - windowsHide: launchSpec.hideWindowsConsole - }) - let settled = false - - function cleanup(): void { - child.off('error', onError) - child.off('spawn', onSpawn) - } - - function settle(callback: () => void): void { - if (settled) { - return - } - settled = true - cleanup() - callback() - } - - function onError(error: Error): void { - settle(() => rejectPromise(error)) - } - - function onSpawn(): void { - child.unref() - settle(resolvePromise) - } - child.once('error', onError) - child.once('spawn', onSpawn) - }) -} - async function openInExternalEditor( store: Store, request: ShellOpenExternalEditorRequest diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 58b7b44637a..c7f850452c5 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -246,6 +246,7 @@ import { RelayVersionMismatchError } from '../ssh/ssh-relay-version-mismatch-err import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, type SshConnectionState, + type SshConnectionStatus, type SshTarget } from '../../shared/ssh-types' import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' @@ -258,6 +259,7 @@ import { getPtyIdsForConnection } from './pty' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { getSshPtyConsumerRecovery } from '../ssh/ssh-pty-consumer-recovery' describe('SSH IPC handlers', () => { const relayBuildId = '0.1.0+ipc-test' @@ -279,6 +281,8 @@ describe('SSH IPC handlers', () => { getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), + markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesAttachedAsync: vi.fn(), removeSshRemotePtyLeases: vi.fn() } const mockWindow = { @@ -354,7 +358,10 @@ describe('SSH IPC handlers', () => { mockStore.getSshRemotePtyLeases.mockReset().mockReturnValue([]) mockStore.markSshRemotePtyLease.mockReset() mockStore.markSshRemotePtyLeases.mockReset() + mockStore.markSshRemotePtyLeasesAsync.mockReset() + mockStore.markSshRemotePtyLeasesAttachedAsync.mockReset() mockStore.removeSshRemotePtyLeases.mockReset() + mockStore.upsertSshPtyConsumerRecovery.mockReset() mockConnectionManager.connect.mockReset() mockConnectionManager.disconnect.mockReset() @@ -526,7 +533,7 @@ describe('SSH IPC handlers', () => { expect(mockPortForwardManager.removeAllForwards).toHaveBeenCalledWith('ssh-1') expect(mockMux.dispose).toHaveBeenCalledWith('shutdown') - expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'terminated') + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'terminated') expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') expect(mockStore.removeSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1') expect(mockSshStore.removeTarget).toHaveBeenCalledWith('ssh-1') @@ -1062,6 +1069,152 @@ describe('SSH IPC handlers', () => { } }) + describe('relay loss while the SSH transport is down', () => { + const relayLostTarget: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const transportState = (status: SshConnectionStatus): SshConnectionState => ({ + targetId: 'ssh-1', + status, + error: null, + reconnectAttempt: 0 + }) + const setTransportStatus = (status: SshConnectionStatus): void => { + mockConnectionManager.getState.mockReturnValue(transportState(status)) + } + const maxRelayDelayMs = relayReconnectDelaysMs.at(-1)! + const connectWithLiveTransport = async (): Promise => { + mockSshStore.getTarget.mockReturnValue(relayLostTarget) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getConnection.mockReturnValue({}) + setTransportStatus('connected') + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + mockDeployAndLaunchRelay.mockClear() + } + + it('does not consume attempts or publish the manual-reconnect banner', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + setTransportStatus('reconnecting') + getLatestRelayDisposeCallback()('connection_lost') + + // Well past the whole 6-step ladder: a redeploy cannot ride a dead transport, so nothing is spent. + const fullLadderMs = relayReconnectDelaysMs.reduce((sum, delay) => sum + delay, 0) + await vi.advanceTimersByTimeAsync(fullLadderMs + relayLostStabilizedMs) + + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual({ + targetId: 'ssh-1', + status: 'reconnecting', + error: 'Relay channel lost. Reconnecting...', + reconnectAttempt: 0, + providerEpoch: expect.any(String), + connectionGeneration: 2 + }) + + setTransportStatus('connected') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + expect( + (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).status + ).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + + it('stops retrying once the transport reaches a terminal state', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + setTransportStatus('reconnecting') + getLatestRelayDisposeCallback()('connection_lost') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + + setTransportStatus('reconnection-failed') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs) + + // The wait loop is gone: only onStateChange's redeploy may revive the relay after this. + setTransportStatus('connected') + await vi.advanceTimersByTimeAsync(maxRelayDelayMs * 4) + expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('resets the relay budget when the connection disappears before retry', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + getLatestRelayDisposeCallback()('connection_lost') + + mockConnectionManager.getConnection.mockReturnValue(undefined) + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + + mockConnectionManager.getConnection.mockReturnValue({}) + getLatestRelayDisposeCallback()('connection_lost') + expect(handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' })).toEqual( + expect.objectContaining({ reconnectAttempt: 1 }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('still reaches the manual-reconnect banner when the transport is healthy', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) + getLatestRelayDisposeCallback()('connection_lost') + for (const delayMs of relayReconnectDelaysMs) { + await vi.advanceTimersByTimeAsync(delayMs) + } + + expect( + (handlers.get('ssh:getState')!(null, { targetId: 'ssh-1' }) as SshConnectionState).error + ).toBe('Relay channel kept dropping. Click Reconnect on the SSH target before retrying.') + } finally { + vi.useRealTimers() + } + }) + + it('restores the full relay budget once the transport reconnects', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + try { + await connectWithLiveTransport() + mockDeployAndLaunchRelay.mockRejectedValue(new Error('relay refused')) + getLatestRelayDisposeCallback()('connection_lost') + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + + const callbacks = mockConnectionManager.callbacksRef.current as { + onStateChange: (targetId: string, state: SshConnectionState) => void + } + callbacks.onStateChange('ssh-1', transportState('reconnecting')) + callbacks.onStateChange('ssh-1', transportState('connected')) + await vi.advanceTimersByTimeAsync(0) + + // Budget reset: the next loss waits the base delay again instead of the third ladder step. + mockDeployAndLaunchRelay.mockClear() + await vi.advanceTimersByTimeAsync(relayReconnectDelaysMs[0]) + expect(mockDeployAndLaunchRelay).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + }) + it('reuses a fast relay reconnect after the post-ready stabilization window', async () => { vi.useFakeTimers() vi.setSystemTime(0) @@ -2473,7 +2626,7 @@ describe('SSH IPC handlers', () => { }) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockStore.markSshRemotePtyLeases.mockClear() + mockStore.markSshRemotePtyLeasesAsync.mockClear() mockStore.markSshRemotePtyLease.mockClear() mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' } @@ -2481,12 +2634,220 @@ describe('SSH IPC handlers', () => { await handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' }) - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalledWith('ssh-1', 'terminated') - expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'detached') + expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalledWith('ssh-1', 'terminated') + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith('ssh-1', 'detached') expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'pty-1', 'expired') expect(mockForceStopRelayForTarget).toHaveBeenCalledWith(conn, 'ssh-1') }) + describe('SSH PTY consumer identity across failed connects', () => { + function makeTarget(id: string): SshTarget { + return { id, label: 'Server', host: 'example.com', port: 22, username: 'deploy' } + } + + function markConnected(targetId: string): void { + mockConnectionManager.getState.mockReturnValue({ + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + } + + it('reclaims the consumer identity after a failed transport connect', async () => { + const targetId = 'ssh-consumer-identity-connect-failure' + let settleLeasePersistence!: () => void + mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce( + () => + new Promise((resolve) => { + settleLeasePersistence = resolve + }) + ) + mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) + mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) + + const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) as Promise + const failure = expect(failedConnect).rejects.toThrow('transport refused') + const settled = vi.fn() + void failedConnect.then(settled, settled) + + await vi.waitFor(() => + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') + ) + expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() + // Why: the connect rejection is gated on the durable 'detached' write, so the retry it + // triggers cannot re-mark leases 'attached' ahead of the abandoned session's release. + expect(settled).not.toHaveBeenCalled() + + settleLeasePersistence() + await failure + const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId + expect(claimedId).toEqual(expect.any(String)) + + mockConnectionManager.connect.mockResolvedValue({}) + markConnected(targetId) + await handlers.get('ssh:connect')!(null, { targetId }) + + expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) + expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( + expect.objectContaining({ targetId, clientInstanceId: claimedId }) + ) + }) + + it('releases the abandoned leases before a fast reconnect re-owns them', async () => { + const targetId = 'ssh-consumer-identity-fast-reconnect' + const order: string[] = [] + let settleLeaseRelease!: () => void + mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { + order.push(`leases:${state}`) + return new Promise((resolve) => { + settleLeaseRelease = () => { + order.push(`leases:${state}:persisted`) + resolve() + } + }) + }) + mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { + order.push('recovery:upsert') + }) + mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) + mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) + + const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) + const failure = expect(failedConnect).rejects.toThrow('transport refused') + await vi.waitFor(() => expect(order).toContain('leases:detached')) + expect(order).not.toContain('leases:detached:persisted') + + settleLeaseRelease() + await failure + + mockConnectionManager.connect.mockResolvedValue({}) + markConnected(targetId) + await handlers.get('ssh:connect')!(null, { targetId }) + + // Why: the reclaimed owner is only re-persisted after the abandoned 'detached' write landed, + // so no late release can strand the reconnected leases in 'detached'. + expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) + }) + + it('holds a retry that starts while the detach write is still pending', async () => { + const targetId = 'ssh-consumer-identity-pending-retry' + const order: string[] = [] + let settleLeaseRelease!: () => void + mockStore.markSshRemotePtyLeasesAsync.mockImplementationOnce((_id: string, state: string) => { + order.push(`leases:${state}`) + return new Promise((resolve) => { + settleLeaseRelease = () => { + order.push(`leases:${state}:persisted`) + resolve() + } + }) + }) + mockStore.upsertSshPtyConsumerRecovery.mockImplementation(async () => { + order.push('recovery:upsert') + }) + mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) + mockConnectionManager.connect.mockRejectedValueOnce(new Error('transport refused')) + + const failedConnect = handlers.get('ssh:connect')!(null, { targetId }) + const failure = expect(failedConnect).rejects.toThrow('transport refused') + await vi.waitFor(() => expect(order).toContain('leases:detached')) + + // Retry mid-write: it must not mint a session or re-own leases while the release is pending. + mockConnectionManager.connect.mockResolvedValue({}) + markConnected(targetId) + const retry = handlers.get('ssh:connect')!(null, { targetId }) as Promise + const retrySettled = vi.fn() + void retry.then(retrySettled, retrySettled) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(retrySettled).not.toHaveBeenCalled() + expect(order).not.toContain('leases:detached:persisted') + + settleLeaseRelease() + await failure + // Why: the retry folds onto the still-latched attempt rather than starting a second connect, + // so it inherits that attempt's failure instead of racing its teardown. + await expect(retry).rejects.toThrow('transport refused') + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + expect(order).toEqual(['leases:detached', 'leases:detached:persisted']) + + await handlers.get('ssh:connect')!(null, { targetId }) + + expect(order).toEqual(['leases:detached', 'leases:detached:persisted', 'recovery:upsert']) + }) + + it('replaces a live session whose detach write keeps failing', async () => { + const targetId = 'ssh-consumer-identity-detach-write-failure' + mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) + mockConnectionManager.connect.mockResolvedValue({}) + markConnected(targetId) + await handlers.get('ssh:connect')!(null, { targetId }) + const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId + expect(claimedId).toEqual(expect.any(String)) + + // Why a permanent reject, not once: it proves the failed session is gone rather than merely + // retried — a second connect that still holds it would fail on the same write again. + mockStore.markSshRemotePtyLeasesAsync.mockImplementation((_id: string, state: string) => + state === 'detached' + ? Promise.reject(new Error('lease write failed')) + : Promise.resolve(undefined) + ) + await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( + 'lease write failed' + ) + + mockConnectionManager.connect.mockClear() + await handlers.get('ssh:connect')!(null, { targetId }) + + expect(mockConnectionManager.connect).toHaveBeenCalledTimes(1) + // Why: the abandoned session still released its identity synchronously, so the replacement + // reclaims the owner instead of minting a new one. + expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) + }) + + it('resumes the remembered owner lease after a failed establish', async () => { + const targetId = 'ssh-consumer-identity-establish-failure' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockSshStore.getTarget.mockReturnValue(makeTarget(targetId)) + mockConnectionManager.connect.mockResolvedValue({}) + markConnected(targetId) + // Why: fail the first request after the consumer session opens, so establish() rejects with an + // owner lease already remembered — the state a retry must be able to resume from. + const openClientResponse = await mockMux.request('pty.openClient') + mockMux.request.mockImplementationOnce(() => Promise.resolve(openClientResponse)) + mockMux.request.mockImplementationOnce(() => + Promise.reject(new Error('relay handshake aborted')) + ) + mockStore.markSshRemotePtyLeasesAsync.mockRejectedValueOnce( + new Error('lease persistence failed') + ) + + try { + await expect(handlers.get('ssh:connect')!(null, { targetId })).rejects.toThrow( + 'relay handshake aborted' + ) + await vi.waitFor(() => expect(warn).toHaveBeenCalled()) + const claimedId = getSshPtyConsumerRecovery(targetId)?.clientInstanceId + expect(claimedId).toEqual(expect.any(String)) + + mockMux.request.mockClear() + await handlers.get('ssh:connect')!(null, { targetId }) + + expect(getSshPtyConsumerRecovery(targetId)?.clientInstanceId).toBe(claimedId) + expect(mockMux.request).toHaveBeenCalledWith( + 'pty.openClient', + expect.objectContaining({ + clientInstanceId: claimedId, + resume: { ownerGeneration: 1, ownerLease: 'ipc-test-owner' } + }), + expect.anything() + ) + } finally { + warn.mockRestore() + } + }) + }) + it('ssh:getState returns connection state', async () => { const state = { targetId: 'ssh-1', diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 7e1a54026df..995d3e88b72 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -21,6 +21,7 @@ import type { import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { isAuthError } from '../ssh/ssh-connection-utils' +import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' @@ -114,7 +115,7 @@ export function listRegisteredRemovedSshTargetLabels(): Record { export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) await runTargetLifecycle(targetId, () => - teardownSshTargetTransport(targetId, (session) => session.detach()) + teardownSshTargetTransport(targetId, (session) => session.detachAndPersist()) ) } @@ -127,7 +128,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise await runTargetLifecycle(targetId, async () => { try { // Why: removal is destructive; dispose so remote PTYs cannot reattach to a deleted target. - await teardownSshTargetTransport(targetId, (session) => session.dispose()) + await teardownSshTargetTransport(targetId, (session) => session.disposeAndPersist()) } catch (err) { // Why: a failed disconnect must not block metadata removal, else the target lingers in the store with uncleaned leases. console.warn( @@ -190,7 +191,7 @@ async function awaitTargetLifecycle(targetId: string): Promise { async function teardownSshTargetTransport( targetId: string, - teardown: (session: SshRelaySession) => void + teardown: (session: SshRelaySession) => void | Promise ): Promise { let transportDisconnect: Promise<{ ok: true } | { ok: false; error: unknown }> try { @@ -219,7 +220,7 @@ async function teardownSshTargetTransport( async function teardownActiveSshSession( targetId: string, - teardown: (session: SshRelaySession) => void + teardown: (session: SshRelaySession) => void | Promise ): Promise { const session = activeSessions.get(targetId) if (!session) { @@ -233,7 +234,7 @@ async function teardownActiveSshSession( teardownError = { error } } try { - teardown(session) + await teardown(session) } catch (error) { teardownError ??= { error } } @@ -247,6 +248,27 @@ async function teardownActiveSshSession( } } +// Why: a dropped session must detach, not just leave activeSessions — detach releases the SSH PTY +// consumer identity so the next connect reclaims its owner lease instead of minting a new one. +// Why awaited, and why the map entry outlives the await: a retry can start the moment this returns, +// so it must either find the session (and await this same latched teardown at the existing-session +// path) or find nothing because the 'detached' lease write is already durable. Deleting first lets a +// fast reconnect mark leases 'attached' and then have this session's late 'detached' write clobber it. +async function abandonFailedSshSession(targetId: string, session: SshRelaySession): Promise { + // Why: detachAndPersist transitions recovery ownership synchronously; only durability is awaited. + try { + await session.detachAndPersist() + } catch (error) { + // Why: a teardown throw must not mask the connect error the caller is about to rethrow. + console.warn( + `[ssh] Failed to detach abandoned session for ${targetId}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (activeSessions.get(targetId) === session) { + activeSessions.delete(targetId) + } +} + function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined { return target?.relayGracePeriodSeconds } @@ -272,10 +294,6 @@ function isCurrentConnectAttempt(targetId: string, authority: DirectSshAuthority return authority.targetId === targetId && isCurrentSshProviderAuthority(authority) } -function connectCancelledError(): Error { - return new Error('SSH connection attempt was cancelled') -} - // Why: publish reset's teardown/force-stop/disconnect lifecycle so new connects and duplicate resets can't race it. const resetRelayInFlight = new Map>() @@ -295,6 +313,13 @@ const RELAY_LOST_BASE_DELAY_MS = 500 const RELAY_LOST_MAX_DELAY_MS = 15_000 // Why: a reconnect whose mux dies within this window was a flap, not a recovery — don't reset the attempt counter. 5s covers provider re-registration + PTY reattach. const RELAY_LOST_STABILIZED_MS = 5_000 +// Why: transport states the SSH ladder never leaves on its own — waiting for a relay redeploy past one of these is an unbounded loop. +const TRANSPORT_TERMINAL_STATUSES = new Set([ + 'disconnected', + 'auth-failed', + 'reconnection-failed', + 'error' +]) function clearRelayLostBackoff(targetId: string): void { const state = relayLostBackoff.get(targetId) @@ -615,6 +640,8 @@ function createSshConnectionCallbacks(): SshConnectionCallbacks { relayStateOverrides.has(targetId) if (shouldReconnectRelay) { + // Why: this branch redeploys the relay itself over a fresh transport, so any pending relay-lost retry is stale — dropping it also gives the new transport generation a full attempt budget. + clearRelayLostBackoff(targetId) // Why: SSH connects before the relay providers rebuild; keep renderer actions gated until SshRelaySession reaches ready again. publishRelayOverride( getCurrentMainWindow, @@ -714,7 +741,13 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { return } rotateSshProviderAuthority(tid) - if (state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { + + // Why: re-deploying the relay rides the SSH transport, so while the transport is itself down no attempt + // can succeed. Waiting at the max delay without consuming the budget keeps a flapping host off the + // manual-reconnect banner, which would tell the user to act on a link that is still auto-recovering. + const transportStatus = connectionManager?.getState(tid)?.status + const transportConnected = transportStatus === 'connected' + if (transportConnected && state.attempts >= RELAY_LOST_MAX_ATTEMPTS) { console.warn( `[ssh] Relay channel for ${tid} kept dying across ${state.attempts} attempts; giving up. User must reconnect manually.` ) @@ -729,6 +762,53 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { ) return } + + const scheduleRelayRedeploy = (delay: number, attemptCharged: boolean): void => { + state.reconnectTimer = setTimeout(() => { + state.reconnectTimer = null + relayLostBackoff.set(tid, state) + const liveConn = connectionManager?.getConnection(tid) + if (!liveConn || !activeSessions.has(tid)) { + clearRelayLostBackoff(tid) + return + } + const status = connectionManager?.getState(tid)?.status + if (status === 'connected') { + if (!attemptCharged) { + // Why: waiting is free, but the deploy it defers is real — charge it here so a transport that + // flaps back to 'connected' can't redeploy forever on an uncharged budget. + state.attempts += 1 + } + void s.reconnect(liveConn, relayGracePeriodForTarget(t)) + return + } + if (status === undefined || TRANSPORT_TERMINAL_STATUSES.has(status)) { + // Why: the transport gave up for good; its own state is what the user acts on, so stop waiting for a redeploy that can never run. + clearRelayLostBackoff(tid) + return + } + // Why: still mid-transition — re-arm at the max delay without consuming an attempt. It ends once + // the transport settles: 'connected' redeploys, a terminal status or a dropped session clears above. + scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) + }, delay) + relayLostBackoff.set(tid, state) + } + + if (!transportConnected) { + publishRelayOverride( + getCurrentMainWindow, + tid, + 'reconnecting', + 'Relay channel lost. Reconnecting...', + state.attempts + ) + scheduleRelayRedeploy(RELAY_LOST_MAX_DELAY_MS, false) + console.warn( + `[ssh] Relay channel for ${tid} lost while the SSH transport is ${transportStatus ?? 'unknown'}; waiting ${RELAY_LOST_MAX_DELAY_MS}ms without consuming an attempt` + ) + return + } + const delay = Math.min(RELAY_LOST_BASE_DELAY_MS * 2 ** state.attempts, RELAY_LOST_MAX_DELAY_MS) state.attempts += 1 publishRelayOverride( @@ -738,16 +818,7 @@ function configureRelaySessionCallbacks(session: SshRelaySession): void { 'Relay channel lost. Reconnecting...', state.attempts ) - state.reconnectTimer = setTimeout(() => { - state.reconnectTimer = null - relayLostBackoff.set(tid, state) - const liveConn = connectionManager?.getConnection(tid) - if (!liveConn || !activeSessions.has(tid)) { - return - } - void s.reconnect(liveConn, relayGracePeriodForTarget(t)) - }, delay) - relayLostBackoff.set(tid, state) + scheduleRelayRedeploy(delay, true) console.warn( `[ssh] Relay channel for ${tid} lost; reconnect attempt ${state.attempts}/${RELAY_LOST_MAX_ATTEMPTS} in ${delay}ms` ) @@ -921,7 +992,7 @@ export function registerSshHandlers( } } if (!isCurrentConnectAttempt(targetId, admissionAuthority)) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } const observedAuthority = admissionAuthority if (existing) { @@ -931,7 +1002,7 @@ export function registerSshHandlers( } } if (!isCurrentSshProviderAuthority(observedAuthority)) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } pendingTransportReconnects.delete(targetId) @@ -994,13 +1065,20 @@ export function registerSshHandlers( // Why: await port teardown before disposing, else the new session's restorePortForwards can hit EADDRINUSE on not-yet-released ports. await portForwardManager!.removeAllForwards(targetId) if (!isCurrentConnectAttempt(targetId, authority)) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } - existingSession.detach() - if (activeSessions.get(targetId) === existingSession) { - activeSessions.delete(targetId) - clearRelayLostBackoff(targetId) - clearRelayStateOverride(targetId) + try { + await existingSession.detachAndPersist() + } finally { + // Why finally: detachAndPersist runs its in-memory half synchronously, so the session is + // dead even when the lease write rejects — keeping it in activeSessions would strand every + // later connect on the same dead session. Why still after the await, not before it: the + // write has settled by now, so it can no longer clobber the replacement's 'attached' write. + if (activeSessions.get(targetId) === existingSession) { + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + clearRelayStateOverride(targetId) + } } } @@ -1010,7 +1088,7 @@ export function registerSshHandlers( throw disconnectResult.error } if (!isCurrentConnectAttempt(targetId, authority)) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } } @@ -1031,18 +1109,18 @@ export function registerSshHandlers( try { conn = await connectionManager!.connect(target) if (!ownsSession()) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } } catch (err) { // Why: connect()'s internal state may not have reached the renderer; broadcast explicitly so the UI leaves 'connecting'. const errObj = err instanceof Error ? err : new Error(String(err)) const status: SshConnectionStatus = isAuthError(errObj) ? 'auth-failed' : 'error' if (!ownsSession()) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } // Why: clear this failed connect's flag so a later non-prompting connect isn't deferred. credentialRequestedForTarget.delete(targetId) - activeSessions.delete(targetId) + await abandonFailedSshSession(targetId, session) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, { @@ -1064,7 +1142,7 @@ export function registerSshHandlers( await session.establish(conn, relayGracePeriodForTarget(target)) if (!ownsSession()) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } // Why: we manually pushed `deploying-relay`, so send `connected` straight to the renderer — routing through onStateChange would trigger reconnect logic. @@ -1078,11 +1156,18 @@ export function registerSshHandlers( }) } catch (err) { if (!ownsSession()) { - throw connectCancelledError() + throw createCancelledConnectAttemptError() } - activeSessions.delete(targetId) + await abandonFailedSshSession(targetId, session) clearRelayLostBackoff(targetId) - await connectionManager!.disconnect(targetId) + try { + await connectionManager!.disconnect(targetId) + } catch (disconnectError) { + // Why: the establish failure is the actionable error; a teardown throw must not replace it. + console.warn( + `[ssh] Failed to disconnect transport after failed establish for ${targetId}: ${disconnectError instanceof Error ? disconnectError.message : String(disconnectError)}` + ) + } throw err } @@ -1152,7 +1237,7 @@ export function registerSshHandlers( // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. throw new Error(`Failed to terminate SSH host sessions: ${shutdownFailures.join('; ')}`) } - await teardownSshTargetTransport(args.targetId, (session) => session.dispose()) + await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist()) }) }) @@ -1171,7 +1256,9 @@ export function registerSshHandlers( const session = activeSessions.get(targetId) if (session) { // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - await teardownActiveSshSession(targetId, (capturedSession) => capturedSession.detach()) + await teardownActiveSshSession(targetId, (capturedSession) => + capturedSession.detachAndPersist() + ) } const existingConn = connectionManager!.getConnection(targetId) @@ -1395,9 +1482,10 @@ export async function resetSshHandlerStateForTests(): Promise { } ipcMain.removeHandler('ssh:submitCredential') - for (const session of activeSessions.values()) { - session.dispose() - } + // Why: allSettled — a rejected disposal write must not abort the rest of the reset and leak state into the next test. + await Promise.allSettled( + [...activeSessions.values()].map((session) => session.disposeAndPersist()) + ) activeSessions.clear() for (const targetId of relayLostBackoff.keys()) { clearRelayLostBackoff(targetId) diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index 72f20291f4a..bfb1c080db3 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -55,17 +55,18 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { expect(worktree.lastActivityAt).toBe(20_000) }) - it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + it('uses linked worktree commit metadata when the .git pointer is stale', async () => { const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') const gitDirHeadPath = path.join(gitDirPath, 'HEAD') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') + const origHeadPath = path.join(gitDirPath, 'ORIG_HEAD') const statPath = vi.fn(async (targetPath: string) => { const mtimes: Record = { '/repo-feature': 10_000, [path.join('/repo-feature', '.git')]: 20_000, - [gitDirPath]: 50_000, [gitDirHeadPath]: 60_000, - [gitDirLogsHeadPath]: 70_000 + [origHeadPath]: 65_000, + [commitEditMsgPath]: 70_000 } return { mtimeMs: mtimes[targetPath] ?? 0 } }) @@ -79,17 +80,86 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { ) expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) - expect(statPath).toHaveBeenCalledWith(gitDirPath) expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) + expect(statPath).toHaveBeenCalledWith(origHeadPath) expect(worktree.lastActivityAt).toBe(70_000) }) + it('ignores gitdir metadata that git maintenance and git status restamp', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 10_000, + [gitDirPath]: 90_000, + [path.join(gitDirPath, 'index')]: 90_000, + [path.join(gitDirPath, 'logs', 'HEAD')]: 90_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).not.toHaveBeenCalledWith(gitDirPath) + expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'index')) + expect(statPath).not.toHaveBeenCalledWith(path.join(gitDirPath, 'logs', 'HEAD')) + expect(worktree.lastActivityAt).toBe(10_000) + }) + + it('reads the newest reflog entry timestamp instead of the reflog file mtime', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const reflogPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + const readTextFile = vi.fn(async (targetPath: string) => + targetPath === reflogPath + ? [ + '0000 1111 Dev 1700000000 -0700\tbranch: Created from HEAD', + '1111 2222 Dev 1700000900 -0700\tcommit: work', + '' + ].join('\n') + : `gitdir: ${gitDirPath}\n` + ) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(reflogPath) + expect(worktree.lastActivityAt).toBe(1_700_000_900_000) + }) + + it('degrades to other probes when the reflog was expired to an empty file', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + const readTextFile = vi.fn(async (targetPath: string) => + targetPath === path.join(gitDirPath, 'logs', 'HEAD') ? '' : `gitdir: ${gitDirPath}\n` + ) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(worktree.lastActivityAt).toBe(10_000) + }) + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') const statPath = vi.fn(async (targetPath: string) => ({ - mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + mtimeMs: targetPath === commitEditMsgPath ? 40_000 : 10_000 })) const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') @@ -100,7 +170,7 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { readTextFile ) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) expect(worktree.lastActivityAt).toBe(40_000) }) @@ -108,14 +178,13 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` const gitDirHeadPath = path.join(gitDirPath, 'HEAD') - const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const commitEditMsgPath = path.join(gitDirPath, 'COMMIT_EDITMSG') const statPath = vi.fn(async (targetPath: string) => { const mtimes: Record = { [worktreePath]: 10_000, [path.join(worktreePath, '.git')]: 20_000, - [gitDirPath]: 50_000, [gitDirHeadPath]: 60_000, - [gitDirLogsHeadPath]: 70_000 + [commitEditMsgPath]: 70_000 } return { mtimeMs: mtimes[targetPath] ?? 0 } }) @@ -129,10 +198,11 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { ) expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) - expect(statPath).toHaveBeenCalledWith(gitDirPath) expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) - expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) - expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(statPath).toHaveBeenCalledWith(commitEditMsgPath) + expect(statPath).not.toHaveBeenCalledWith( + path.join('/home/me/repo/.git/worktrees/repo-feature', 'HEAD') + ) expect(worktree.lastActivityAt).toBe(70_000) }) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 9026b4464d9..56ac36f6b29 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,20 +1,13 @@ import { lstat, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo, Worktree } from '../../shared/types' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> type ReadTextFile = (targetPath: string) => Promise -export function getPersistedWorkspaceCleanupActivityAt( - worktree: Pick -): number { - const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 - const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 - return Math.max(persistedActivityAt, createdAt) -} - export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) if (persistedActivityAt <= worktree.lastActivityAt) { @@ -56,7 +49,7 @@ async function resolveWorkspaceCleanupActivityAt( return persistedActivityAt } - const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + const filesystemActivityAt = await getNewestLocalWorktreeActivityAt( worktree.path, statPath, readTextFile @@ -67,26 +60,54 @@ async function resolveWorkspaceCleanupActivityAt( // Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely // report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the // persisted activity timestamp instead of blocking or mislabeling the row. -async function getNewestLocalWorktreeStatMtime( +async function getNewestLocalWorktreeActivityAt( worktreePath: string, statPath: StatPath, readTextFile: ReadTextFile ): Promise { const gitPath = path.join(worktreePath, '.git') const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) - const gitDirMtimePromises = gitDirPath + // Why: every entry here must move only on a user action. Excluded on purpose: + // the gitdir directory and logs/HEAD (restamped for every linked worktree at + // once by `git gc` / `git reflog expire`, which made one maintenance run look + // like activity everywhere), and index (rewritten by `git status` — this scan + // runs one per candidate, so it would erase the evidence it just read). + const gitDirProbes = gitDirPath ? [ - readMtime(gitDirPath, statPath), readMtime(path.join(gitDirPath, 'HEAD'), statPath), - readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + readMtime(path.join(gitDirPath, 'COMMIT_EDITMSG'), statPath), + readMtime(path.join(gitDirPath, 'ORIG_HEAD'), statPath), + readNewestReflogEntryAt(path.join(gitDirPath, 'logs', 'HEAD'), readTextFile) ] : [] - const mtimes = await Promise.all([ + const timestamps = await Promise.all([ readMtime(worktreePath, statPath), readMtime(gitPath, statPath), - ...gitDirMtimePromises + ...gitDirProbes ]) - return Math.max(0, ...mtimes) + return Math.max(0, ...timestamps) +} + +// Why: the reflog records when HEAD actually moved, so it survives the mtime +// churn that makes logs/HEAD itself unreadable as a signal. Expired reflogs are +// truncated to an empty file, which degrades to the other probes. +async function readNewestReflogEntryAt( + reflogPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const lines = (await readTextFile(reflogPath)).split('\n') + for (let index = lines.length - 1; index >= 0; index -= 1) { + // The trailing timezone + tab anchors the capture, so no digit-count floor is needed. + const seconds = /\s(\d{1,11})\s[-+]\d{4}\t/.exec(lines[index] ?? '')?.[1] + if (seconds) { + return Number(seconds) * 1000 + } + } + return 0 + } catch { + return 0 + } } async function readLocalWorktreeGitDir( diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index f5deb16a2bd..32794f61977 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -13,8 +13,8 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { getPersistedWorkspaceCleanupActivityAt } from '../../shared/workspace-cleanup' import { - getPersistedWorkspaceCleanupActivityAt, resolvePersistedWorkspaceCleanupActivityWorktree, resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' diff --git a/src/main/ipc/worktree-change-invalidators.ts b/src/main/ipc/worktree-change-invalidators.ts index a43657743b4..156ce0f722c 100644 --- a/src/main/ipc/worktree-change-invalidators.ts +++ b/src/main/ipc/worktree-change-invalidators.ts @@ -19,7 +19,3 @@ export function runWorktreeChangeInvalidators(repoId: string): void { } } } - -export function __resetWorktreeChangeInvalidatorsForTests(): void { - worktreeChangeInvalidators.clear() -} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 3be2a173ee1..b09b4e71c93 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -38,7 +38,6 @@ import type { ForgeProviderId } from '../source-control/forge-provider' import { validateGitPushTarget } from '../git/push-target-validation' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from '../git/runner' -import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { OrcaRuntimeService, RemoteFetchResult, @@ -55,6 +54,7 @@ import { getSetupRunnerEnvVars, loadHooks, parseOrcaYaml, + resolveSetupRunnerShell, shouldRunSetupForCreate } from '../hooks' import { requireSshGitProvider } from '../providers/ssh-git-dispatch' @@ -93,10 +93,13 @@ import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace- import { cleanupUnusedWorktreePushTargetRemoteWithExec, sameGitHubRemoteUrl, + type GitRemoteExec, type WorktreePushTargetStore } from './worktree-push-target-cleanup' import { configureCreatedWorktreePushTargetWithExec, + ensureUniqueRemoteName, + findRemoteForUrl, prepareWorktreePushTargetWithExec } from './worktree-push-target-setup' import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' @@ -176,6 +179,13 @@ function appendWorktreeCreateWarning(current: string | undefined, next: string): return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next } +function getSetupRunnerCommandPlatformForLaunch( + setup: CreateWorktreeResult['setup'], + fallbackPlatform: 'windows' | 'posix' +): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) +} + function validateWorkspaceLineageParentBeforeCreate( store: Store, parentWorkspace: CreateWorktreeArgs['parentWorkspace'], @@ -264,14 +274,15 @@ async function spawnLocalStartupAndSetupTerminals(args: { let sequencedStartup = startup let wrappedSetupCommandStr: string | undefined if (startup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + const platform = getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: startup.command, - platform + platform, + shell: setup.shell }) sequencedStartup = { ...startup, @@ -327,10 +338,11 @@ async function spawnLocalStartupAndSetupTerminals(args: { wrappedSetupCommandStr ?? buildSetupRunnerCommand( setup.runnerScriptPath, - getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' - ) + ), + setup.shell ) const setupLaunchMode = (settings as Partial>) @@ -1048,67 +1060,6 @@ export async function configureCreatedWorktreePushTarget( ) } -async function findRemoteForUrlSsh( - provider: SshGitProvider, - repoPath: string, - remoteUrl: string -): Promise { - const target = parseGitHubOwnerRepo(remoteUrl) - try { - const { stdout } = await provider.exec(['remote'], repoPath) - for (const remote of stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - try { - const { stdout: urlStdout } = await provider.exec(['remote', 'get-url', remote], repoPath) - const candidateUrl = urlStdout.trim() - const candidate = parseGitHubOwnerRepo(candidateUrl) - if ( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) { - return remote - } - if (candidateUrl === remoteUrl) { - return remote - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } - } catch { - return null - } - return null -} - -async function ensureUniqueRemoteNameSsh( - provider: SshGitProvider, - repoPath: string, - preferred: string -): Promise { - const { stdout } = await provider.exec(['remote'], repoPath) - const existing = new Set( - stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - ) - if (!existing.has(preferred)) { - return preferred - } - for (let suffix = 2; suffix < 100; suffix += 1) { - const candidate = `${preferred}-${suffix}` - if (!existing.has(candidate)) { - return candidate - } - } - throw new Error(`Could not find an available remote name for ${preferred}.`) -} - async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, @@ -1117,12 +1068,13 @@ async function prepareWorktreePushTargetSsh( repoId?: string ): Promise { assertGitPushTargetShape(target) + const execGit: GitRemoteExec = (args, cwd) => provider.exec(args, cwd) const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target await provider.exec(['check-ref-format', '--branch', target.branchName], repoPath) let remoteName = target.remoteName let remoteCreated = false if (target.remoteUrl) { - const existingRemote = await findRemoteForUrlSsh(provider, repoPath, target.remoteUrl) + const existingRemote = await findRemoteForUrl(execGit, repoPath, target.remoteUrl) if (existingRemote) { remoteName = existingRemote // Why: a reused Orca-created fork remote must inherit ownership so deleting the final user can remove it. @@ -1137,7 +1089,7 @@ async function prepareWorktreePushTargetSsh( ) : false } else { - remoteName = await ensureUniqueRemoteNameSsh(provider, repoPath, target.remoteName) + remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath) remoteCreated = true } @@ -1174,19 +1126,6 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh( } } -async function configureCreatedWorktreePushTargetSsh( - provider: SshGitProvider, - worktreePath: string, - branchName: string, - target: GitPushTarget -): Promise { - await provider.exec( - ['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName], - worktreePath - ) - return target -} - async function readRemoteEffectiveHooks( repo: Repo, fsProvider: IFilesystemProvider, @@ -1215,6 +1154,8 @@ async function createRemoteSetupRunnerScript( fsProvider: IFilesystemProvider ): Promise { const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath) + // Why: SSH terminals choose their shell on the remote host; local Windows + // preferences cannot safely select a remote runner format or launch command. const runnerRelativePath = useWindowsFormat ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh' const { stdout } = await gitProvider.exec( ['rev-parse', '--git-path', runnerRelativePath], @@ -1825,8 +1766,8 @@ export async function createRemoteWorktree( const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { - configuredPushTarget = await configureCreatedWorktreePushTargetSsh( - provider, + configuredPushTarget = await configureCreatedWorktreePushTargetWithExec( + (args, cwd) => provider.exec(args, cwd), created.path, branchName, preparedPushTarget @@ -2575,11 +2516,13 @@ export async function createLocalWorktree( try { // Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior. // Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation. + // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, setupScript, - ...localWorktreeGitOptionArgs + localWorktreeGitOptionArgs[0], + resolveSetupRunnerShell(settings) ) } catch (error) { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index 5a7c2789260..ee9540048cd 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -20,6 +20,7 @@ const { getDefaultTabsLaunchMock, createIssueCommandRunnerScriptMock, createSetupRunnerScriptMock, + resolveSetupRunnerShellMock, shouldRunSetupForCreateMock, runHookMock, hasHooksFileMock, @@ -49,6 +50,7 @@ const { getDefaultTabsLaunchMock: vi.fn(), createIssueCommandRunnerScriptMock: vi.fn(), createSetupRunnerScriptMock: vi.fn(), + resolveSetupRunnerShellMock: vi.fn(), shouldRunSetupForCreateMock: vi.fn(), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), @@ -110,6 +112,7 @@ vi.mock('../hooks', () => ({ loadHooks: loadHooksMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, + resolveSetupRunnerShell: resolveSetupRunnerShellMock, shouldRunSetupForCreate: shouldRunSetupForCreateMock })) @@ -177,6 +180,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getDefaultTabsLaunchMock.mockReset() createIssueCommandRunnerScriptMock.mockReset() createSetupRunnerScriptMock.mockReset() + resolveSetupRunnerShellMock.mockReset() shouldRunSetupForCreateMock.mockReset() runHookMock.mockReset() hasHooksFileMock.mockReset() @@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { refreshLocalBaseRefOnWorktreeCreate: false, workspaceDir: 'C:\\workspaces' }) + resolveSetupRunnerShellMock.mockReturnValue(undefined) store.getWorktreeMeta.mockReturnValue(undefined) store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') @@ -345,6 +350,61 @@ describe('registerWorktreeHandlers – Windows path handling', () => { ) }) + it('passes the configured Windows setup shell into local setup runner generation', async () => { + const setupShell = { family: 'posix' as const } + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspaces' + }) + resolveSetupRunnerShellMock.mockReturnValue(setupShell) + listWorktreesMock.mockResolvedValue([ + { + path: 'C:/workspaces/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + createSetupRunnerScriptMock.mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: setupShell, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\improve-dashboard' + } + }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith( + expect.objectContaining({ terminalWindowsShell: 'git-bash' }) + ) + expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\improve-dashboard', + 'pnpm install', + undefined, + setupShell + ) + expect(result).toMatchObject({ + setup: { + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: setupShell + } + }) + }) + it('preserves create-time metadata on the next list when Windows path formatting differs', async () => { const worktreeEntry = { path: 'C:/workspaces/improve-dashboard', @@ -427,7 +487,10 @@ describe('registerWorktreeHandlers – Windows path handling', () => { knownRemovedWorktree: registeredWorktree }) ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::C:/workspaces/improve-dashboard') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::C:/workspaces/improve-dashboard', + 'local' + ) // Windows history lives under a path-derived hash, so scheduling must not regress on this path only. expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( 'repo-1::C:/workspaces/improve-dashboard' @@ -436,4 +499,29 @@ describe('registerWorktreeHandlers – Windows path handling', () => { repoId: 'repo-1' }) }) + it('gives the issue-command runner the same setup shell as the setup runner', () => { + // Regression (C4): native Windows issue runners stayed .cmd even when setup + // resolved to Git Bash, so same-session bash issue templates broke. + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + createIssueCommandRunnerScriptMock.mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh', + envVars: {}, + shell: { family: 'posix' } + }) + + handlers['hooks:createIssueCommandRunner'](null, { + repoId: 'repo-1', + worktreePath: 'C:\\workspaces\\improve-dashboard', + command: 'gh issue view 42' + }) + + expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith(store.getSettings()) + expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\improve-dashboard', + 'gh issue view 42', + expect.anything(), + { family: 'posix' } + ) + }) }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 2dec930beaf..811fae77b8d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -50,6 +50,7 @@ const { buildPosixRunnerScriptMock, buildWindowsRunnerScriptMock, getSetupRunnerEnvVarsMock, + resolveSetupRunnerShellMock, runHookMock, hasHooksFileMock, loadHooksMock, @@ -100,6 +101,7 @@ const { buildPosixRunnerScriptMock: vi.fn(), buildWindowsRunnerScriptMock: vi.fn(), getSetupRunnerEnvVarsMock: vi.fn(), + resolveSetupRunnerShellMock: vi.fn(), runHookMock: vi.fn(), hasHooksFileMock: vi.fn(), loadHooksMock: vi.fn(), @@ -199,6 +201,7 @@ vi.mock('../hooks', () => ({ getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock, loadHooks: loadHooksMock, parseOrcaYaml: parseOrcaYamlMock, + resolveSetupRunnerShell: resolveSetupRunnerShellMock, runHook: runHookMock, hasHooksFile: hasHooksFileMock, shouldRunSetupForCreate: shouldRunSetupForCreateMock @@ -257,7 +260,11 @@ import { __resetSshWorktreeCreateFetchCacheForTests, notifyWorktreesChanged } from './worktree-remote' -import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' +import { + invalidateAuthorizedRootsCache, + registerWorktreeRootsForRepo, + resolveRegisteredWorktreePath +} from './filesystem-auth' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import type { RedactableSpan } from '../observability/redactor' import { @@ -304,6 +311,7 @@ describe('registerWorktreeHandlers', () => { setWorktreeMeta: vi.fn(), getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), + removeWorkspaceSessionStateForWorktree: vi.fn(), getAllWorktreeLineage: vi.fn(), removeWorktreeLineage: vi.fn(), getAllWorkspaceLineage: vi.fn(), @@ -364,6 +372,7 @@ describe('registerWorktreeHandlers', () => { buildPosixRunnerScriptMock, buildWindowsRunnerScriptMock, getSetupRunnerEnvVarsMock, + resolveSetupRunnerShellMock, shouldRunSetupForCreateMock, runHookMock, hasHooksFileMock, @@ -385,6 +394,7 @@ describe('registerWorktreeHandlers', () => { store.setWorktreeMeta, store.getProjectHostSetups, store.removeWorktreeMeta, + store.removeWorkspaceSessionStateForWorktree, store.getAllWorktreeLineage, store.removeWorktreeLineage, store.getAllWorkspaceLineage, @@ -478,6 +488,7 @@ describe('registerWorktreeHandlers', () => { (script: string) => `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n` ) buildWindowsRunnerScriptMock.mockImplementation((script: string) => script) + resolveSetupRunnerShellMock.mockReturnValue(undefined) getSetupRunnerEnvVarsMock.mockImplementation( (repoArg: { path: string }, worktreePath: string) => ({ ORCA_ROOT_PATH: repoArg.path, @@ -1220,10 +1231,11 @@ describe('registerWorktreeHandlers', () => { getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) shouldRunSetupForCreateMock.mockReturnValue(true) createSetupRunnerScriptMock.mockReturnValueOnce({ - runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { - ORCA_ROOT_PATH: '/workspace/repo', - ORCA_WORKTREE_PATH: '/workspace/improve-dashboard' + ORCA_ROOT_PATH: 'C:\\workspace\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspace\\improve-dashboard' }, waitForAgentStartup: true }) @@ -1248,8 +1260,8 @@ describe('registerWorktreeHandlers', () => { expect(result.setup).toEqual( expect.objectContaining({ - runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', - command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh') + runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh', + command: expect.stringContaining('bash /mnt/c/workspace/repo/.git/orca/setup-runner.sh') }) ) expect(result.setup?.command).toContain('printf') @@ -4938,6 +4950,103 @@ describe('registerWorktreeHandlers', () => { ) }) + it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\remote\\repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: 'origin/main' + } + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args[1] === '--git-path') { + return { + stdout: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n', + stderr: '' + } + } + if (args[0] === 'rev-parse') { + throw new Error('missing local branch') + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: 'C:\\remote\\improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const fsProvider = { + readFile: vi.fn().mockResolvedValue({ + content: 'scripts:\n setup: pnpm install\n', + isBinary: false + }), + createDir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined) + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + terminalWindowsShell: 'git-bash', + workspaceDir: 'C:\\workspace' + }) + getSshGitProviderMock.mockReturnValue(provider) + getSshFilesystemProviderMock.mockReturnValue(fsProvider) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } }) + shouldRunSetupForCreateMock.mockReturnValue(true) + resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard', + setupDecision: 'run' + }) + + expect(provider.exec).toHaveBeenCalledWith( + ['rev-parse', '--git-path', 'orca/setup-runner.cmd'], + 'C:\\remote\\improve-dashboard' + ) + expect(fsProvider.writeFile).toHaveBeenCalledWith( + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + 'pnpm install' + ) + expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled() + expect(result).toEqual( + expect.objectContaining({ + setup: { + runnerScriptPath: + 'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd', + envVars: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\remote\\repo', + ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard' + }) + } + }) + ) + }) + it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => { const repo = { id: 'repo-ssh', @@ -6636,7 +6745,9 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', 'codex exec "long command"', - {} + {}, + // Why: issue runners take the resolved setup shell; it is undefined off Windows. + undefined ) expect(result).toMatchObject({ runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh', @@ -7615,7 +7726,9 @@ describe('registerWorktreeHandlers', () => { expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', - 'pnpm worktree:setup' + 'pnpm worktree:setup', + undefined, + undefined ) expect(result).toMatchObject({ worktree: expect.objectContaining({ @@ -7676,7 +7789,8 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', 'pnpm worktree:setup', - { wslDistro: 'Ubuntu' } + { wslDistro: 'Ubuntu' }, + undefined ) expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -7713,7 +7827,9 @@ describe('registerWorktreeHandlers', () => { expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), '/workspace/improve-dashboard', - 'pnpm worktree:setup # worktree' + 'pnpm worktree:setup # worktree', + undefined, + undefined ) expect(result).toEqual( expect.objectContaining({ @@ -8005,7 +8121,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt') expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -8046,7 +8162,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' }) @@ -8160,7 +8276,7 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { cwd: '/workspace/repo' }) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') }) it('preserves a locked missing registration even with force', async () => { @@ -8240,7 +8356,7 @@ describe('registerWorktreeHandlers', () => { expect(killAllProcessesForWorktreeMock.mock.invocationCallOrder[0]).toBeLessThan( store.removeWorktreeMeta.mock.invocationCallOrder[0] ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { @@ -8873,6 +8989,53 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).not.toHaveBeenCalled() }) + it('tears down the remote session when an ownerless remote worktree is deleted', async () => { + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'ssh', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1', + worktreeBaseRef: null + } + const provider = { + listWorktrees: vi.fn().mockResolvedValue([ + { + path: sshRepo.path, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: '/remote/feature-wt', + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]), + removeWorktree: vi.fn().mockResolvedValue(undefined), + worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }) + } + store.getRepo.mockReturnValue(sshRepo) + store.getRepos.mockReturnValue([sshRepo]) + getSshGitProviderMock.mockReturnValue(provider) + // Why: no WorktreeMeta means removal cannot read the owner back; the repo is the only host source. + store.getWorktreeMeta.mockReturnValue(undefined) + + await handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/remote/feature-wt' + }) + + // The whole point: without the repo's host the ownerless row would clear the local session instead. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/remote/feature-wt', + 'ssh:conn-1' + ) + }) + it('fails closed when duplicate repo ids are deleted without a host', async () => { const localRepo = { id: 'repo-shared', @@ -9283,7 +9446,10 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/already-deleted-wt') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/already-deleted-wt', + 'local' + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' ) @@ -9306,7 +9472,7 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -9327,7 +9493,10 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' ) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/already-deleted-wt') + expect(store.removeWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/already-deleted-wt', + 'local' + ) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith( 'repo-1::/workspace/already-deleted-wt' ) @@ -9373,7 +9542,7 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -9468,7 +9637,7 @@ describe('registerWorktreeHandlers', () => { expect(runHookMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-1' @@ -10249,21 +10418,29 @@ describe('registerWorktreeHandlers', () => { } const ptyProvider = {} as never const worktreeId = 'repo-1::/workspace/feature-wt' + store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) getLocalPtyProviderMock.mockReturnValue(ptyProvider) // Why: a removed/disconnected SSH target has no live provider; forgetLocal must not reach for one. getSshGitProviderMock.mockReturnValue(undefined) + getSshPtyProviderMock.mockReturnValue(undefined) const result = await handlers['worktrees:forgetLocal'](null, { worktreeId }) expect(result).toEqual({}) expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { runtime: runtimeStub, + // Without the exact id the sweep resolves a selector that no longer exists and stops nothing. + resolvedWorktreeId: worktreeId, + resolvedConnectionId: 'ssh-dead', localProvider: ptyProvider, - onPtyStopped: clearProviderPtyStateMock + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: false, + includeLocalRegistry: false }) expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + // The purge must be scoped to the same owner the sweep used, or the ssh:* partition keeps this worktree's session state. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-dead') expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { @@ -10276,17 +10453,115 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).not.toHaveBeenCalled() }) - it('throws when the repo is missing', async () => { + it('sweeps a connected SSH owner through its PTY provider', async () => { + const worktreeId = 'repo-gone::/workspace/feature-wt' + const sshProvider = {} as never + store.getRepos.mockReturnValue([]) + store.getRepo.mockReturnValue(undefined) + store.getWorktreeMeta.mockReturnValue({ hostId: 'ssh:ssh-live' }) + getSshPtyProviderMock.mockReturnValue(sshProvider) + + await expect( + handlers['worktrees:forgetLocal'](null, { worktreeId, hostId: 'ssh:ssh-live' }) + ).resolves.toEqual({}) + + expect(getSshPtyProviderMock).toHaveBeenCalledWith('ssh-live') + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + resolvedWorktreeId: worktreeId, + resolvedConnectionId: 'ssh-live', + localProvider: sshProvider, + onPtyStopped: clearProviderPtyStateMock, + includeProviderInventory: true, + includeLocalRegistry: false + }) + expect(getLocalPtyProviderMock).not.toHaveBeenCalled() + }) + + it('forgets an ownerless workspace after its repo is already gone', async () => { + const worktreeId = 'repo-gone::/workspace/feature-wt' + const worktreePath = '/workspace/feature-wt' + // Seed authorized roots while the owning repo still exists, then let it disappear. + store.getRepos.mockReturnValue([ + { + id: 'repo-gone', + path: '/workspace/gone', + displayName: 'gone', + badgeColor: '#000', + addedAt: 0 + } + ]) + registerWorktreeRootsForRepo(store as never, 'repo-gone', [worktreePath]) + await expect(resolveRegisteredWorktreePath(worktreePath, store as never)).resolves.toBe( + resolve(worktreePath) + ) + store.getRepos.mockReturnValue([]) store.getRepo.mockReturnValue(undefined) await expect( handlers['worktrees:forgetLocal'](null, { - worktreeId: 'repo-gone::/workspace/feature-wt' + worktreeId, + hostId: 'runtime:env-1' }) - ).rejects.toThrow(/Repo not found/) + ).resolves.toEqual({}) - expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + // The whole point: a forgotten workspace must not stay filesystem-authorized via cached roots. + await expect(resolveRegisteredWorktreePath(worktreePath, store as never)).rejects.toThrow( + 'Access denied: unknown repository or worktree path' + ) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'runtime:env-1') + expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-gone' + }) expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('purges the SSH owner partition for a hostId-less forget whose worktreeMeta row is already gone', async () => { + const repo = { + id: 'repo-ssh', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'ssh:ssh-live' as const, + connectionId: 'ssh-live', + worktreeBaseRef: null + } + const worktreeId = 'repo-ssh::/workspace/feature-wt' + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + // The orphan case forget-local exists for: the meta row is lost, so only the repo still records ownership. + store.getWorktreeMeta.mockReturnValue(undefined) + getSshPtyProviderMock.mockReturnValue(undefined) + getLocalPtyProviderMock.mockReturnValue({} as never) + + await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) + + // Without the resolved owner the purge resolves to [local] only and ssh:ssh-live keeps tabsByWorktree et al. forever. + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-live') + }) + + it('scopes the purge to a local folder workspace owner', async () => { + const repo = { + id: 'repo-folder-child', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + const worktreeId = 'repo-folder-child::/workspace/folder/child' + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.getWorktreeMeta.mockReturnValue(undefined) + getLocalPtyProviderMock.mockReturnValue({} as never) + + await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(getSshPtyProviderMock).not.toHaveBeenCalled() }) it('rejects forgetting a folder project root', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index baa5f496e58..103464dc55e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -91,6 +91,7 @@ import { loadHooks, parseOrcaYaml, readIssueCommand, + resolveSetupRunnerShell, runHook, hasHooksFile, hasUnrecognizedOrcaYamlKeys, @@ -287,9 +288,31 @@ async function mapWithConcurrency( return results } -function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: string): void { +// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner +// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. +function resolveWorktreeRemovalOwnerHostId( + store: Store, + worktreeId: string, + repo: Repo | undefined, + fallbackHostId?: ExecutionHostId +): ExecutionHostId | undefined { + return ( + store.getWorktreeMeta(worktreeId)?.hostId ?? + (repo ? getRepoExecutionHostId(repo) : fallbackHostId) + ) +} + +function removeWorktreeMetadataAndTransientState( + store: Store, + worktreeId: string, + hostId?: ExecutionHostId +): void { // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. - store.removeWorktreeMeta(worktreeId) + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } advertisedUrlWatcher.forgetWorktree(worktreeId) // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) @@ -2230,10 +2253,9 @@ export function registerWorktreeHandlers( if (!repo) { throw new Error(`Repo not found: ${repoId}`) } - const inFlightKey = getWorktreeRemovalInFlightKey( - args.worktreeId, - getRepoExecutionHostId(repo) - ) + // The resolved repo supplies host ownership when legacy callers omit args.hostId. + const removalHostId = getRepoExecutionHostId(repo) + const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) if (inFlightRemoval) { @@ -2264,7 +2286,7 @@ export function registerWorktreeHandlers( }) }) await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) @@ -2373,7 +2395,7 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2416,7 +2438,7 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) @@ -2448,7 +2470,7 @@ export function registerWorktreeHandlers( invalidateAuthorizedRootsCache() } runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2502,7 +2524,7 @@ export function registerWorktreeHandlers( removedPushTarget ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2601,7 +2623,7 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) notifyWorktreesChanged(mainWindow, repoId) return removalResult ?? {} @@ -2745,7 +2767,7 @@ export function registerWorktreeHandlers( localWorktreeGitOptions ) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) preservedBranchCleanupByWorktreeId.delete(args.worktreeId) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) @@ -2776,7 +2798,7 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { invalidateAuthorizedRootsCache() @@ -2812,13 +2834,12 @@ export function registerWorktreeHandlers( ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - // Why: share the removal in-flight map so concurrent remove and forgetLocal on the same id can't both mutate metadata. + // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. const inFlightKey = getWorktreeRemovalInFlightKey( args.worktreeId, - getRepoExecutionHostId(repo) + repo + ? getRepoExecutionHostId(repo) + : (store.getWorktreeMeta(args.worktreeId)?.hostId ?? args.hostId) ) const optionsKey = 'forget-local' const inFlight = worktreeRemovalsInFlight.get(inFlightKey) @@ -2830,23 +2851,50 @@ export function registerWorktreeHandlers( } const forget = (async (): Promise => { - if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { + // Ambiguous repo lookup must not bypass a live folder root's deletion guard. + const isFolderRootOf = (candidate: Repo): boolean => + isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) + if (repo ? isFolderRootOf(repo) : store.getRepos().some(isFolderRootOf)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } - // Why: best-effort PTY sweep; resolves synchronously for a dead SSH relay (tombstoned lease) so it never hangs. + const ownerHostId = resolveWorktreeRemovalOwnerHostId( + store, + args.worktreeId, + repo, + args.hostId + ) + const ownerHost = parseExecutionHostId(ownerHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + // External host inventories must never sweep a same-id local workspace. await killAllProcessesForWorktree(args.worktreeId, { runtime, - localProvider: getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) }).catch((err) => { console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) }) runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState(store, args.worktreeId) + // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. + removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) + // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. + invalidateAuthorizedRootsCache() preservedBranchCleanupByWorktreeId.delete(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) return {} @@ -3071,7 +3119,8 @@ export function registerWorktreeHandlers( repo, args.worktreePath, args.command, - getLocalProjectWorktreeGitOptions(store, repo) + getLocalProjectWorktreeGitOptions(store, repo), + resolveSetupRunnerShell(store.getSettings()) ) } ) diff --git a/src/main/jetbrains-windows-gui-launchers.ts b/src/main/jetbrains-windows-gui-launchers.ts new file mode 100644 index 00000000000..900b1339745 --- /dev/null +++ b/src/main/jetbrains-windows-gui-launchers.ts @@ -0,0 +1,91 @@ +import { win32 } from 'node:path' +import { + getLauncherBaseName, + isWindowsBatchLauncher, + stripMatchingQuotes +} from './editor-launcher-name' + +// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The +// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe` +// stubs) chain through helpers that allocate a visible Command Prompt even +// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop. +// Maps each short console launcher name to its GUI sibling. +const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly> = { + idea: 'idea64', + webstorm: 'webstorm64', + pycharm: 'pycharm64', + phpstorm: 'phpstorm64', + goland: 'goland64', + rider: 'rider64', + clion: 'clion64', + rubymine: 'rubymine64', + datagrip: 'datagrip64', + rustrover: 'rustrover64', + studio: 'studio64' +} + +const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI)) + +/** GUI binary basename for a console or GUI launcher name, or null if unknown. */ +function getGuiExecutableName(launcherBaseName: string): string | null { + if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) { + return launcherBaseName + } + return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null +} + +/** + * The GUI `*64.exe` beside an already-resolved console entry point, or null. + * + * Why sibling-only: a bare PATH lookup for `idea64` can land in a different, + * stale install, and it costs a second full PATH walk on Electron's main + * thread. Toolbox script directories ship no exe, so those keep the shim and + * rely on the detached-GUI spawn instead. + * + * Upgrades: + * - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present + * - short-name console stubs `idea.exe` → colocated `idea64.exe` when present + * - leaves `idea64.exe` and non-JetBrains launchers unchanged + */ +export function resolveColocatedJetBrainsGuiExecutable( + resolvedCommand: string, + fileExists: (path: string) => boolean +): string | null { + const unquoted = stripMatchingQuotes(resolvedCommand) + const baseName = getLauncherBaseName(unquoted) + const isBatch = /\.(?:cmd|bat)$/i.test(unquoted) + const isExe = /\.exe$/i.test(unquoted) + if (!isBatch && !isExe) { + return null + } + + // .exe: only short console names (idea.exe), never the GUI binary itself. + if (isExe) { + const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName] + if (!guiName) { + return null + } + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) + return fileExists(candidate) ? candidate : null + } + + // .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated. + const guiName = getGuiExecutableName(baseName) + if (!guiName) { + return null + } + const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`) + return fileExists(candidate) ? candidate : null +} + +// Why: only JetBrains batch shims chain through console helpers that outlive +// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit +// on their own, and running them under `start` would re-parse their quoted +// WSL/SSH remote argv. detachedGui only rewrites batch targets. +export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean { + return ( + platform === 'win32' && + isWindowsBatchLauncher(command) && + getGuiExecutableName(getLauncherBaseName(command)) !== null + ) +} diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index ec672d7e9c8..6608fd2641c 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -19,7 +19,7 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format' import { splitWorktreeId } from '../../shared/worktree-id' import type { Store } from '../persistence' -// Why: attachMainWindowServices reruns on every macOS dock re-activation; guard against re-running git I/O + daemon RPC. +// Why: macOS dock reactivation reruns setup; avoid repeating Git I/O and daemon RPC. let hasHydrated = false /** @@ -42,7 +42,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick [repo.id, repo])) // Why: verify via live git that a referenced worktree still exists, to avoid resurrecting removed ones. const liveLocalWorktreeIds = new Set() @@ -51,7 +51,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick p.ptyId)) - // Why: git enumeration is slow, so re-read daemon+registry each pass to avoid resurrecting exited sessions; terminates as each pass resolves >=1 new repo. + // Why: re-read live sessions each pass to avoid resurrecting exited sessions. for (;;) { const newlyReferencedRepos = new Map[number]>() for (const info of sessionInfos) { @@ -65,7 +65,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick { consent = null } -export function getObservabilityConsent(): ObservabilityConsent | null { - return consent -} - // ── Bundle / trace-folder operations exposed to IPC ───────────────────── export type DiagnosticsStatus = { diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index 3e944b23847..d4ab5d4e497 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -228,32 +228,6 @@ export async function withWorktreeRemoveStageSpan( }) } -export type PtySpanArgs = { - readonly stage: 'spawn' | 'exit' | 'recover' - readonly shell?: string - readonly cwd?: string -} - -/** Wrap a PTY-lifecycle event in a `pty.` span. The lifecycle is - * long-lived; callers typically use `startSpan` directly for the live - * session and call `withPtySpan` only for the spawn/exit moments. */ -export async function withPtySpan(meta: PtySpanArgs, fn: () => Promise | T): Promise { - return withSpan( - `pty.${meta.stage}`, - async (span) => { - span.setAttribute('pty.stage', meta.stage) - if (meta.shell) { - span.setAttribute('pty.shell', meta.shell) - } - if (meta.cwd) { - span.setAttribute('cwd', meta.cwd) - } - return await fn() - }, - { attributes: { kind: 'pty' } } - ) -} - export type UpdaterSpanArgs = { readonly stage: 'check' | 'download' | 'install' } diff --git a/src/main/observability/redactor.ts b/src/main/observability/redactor.ts index 173382bde58..cd5570f8577 100644 --- a/src/main/observability/redactor.ts +++ b/src/main/observability/redactor.ts @@ -234,14 +234,3 @@ export function redactSpan(span: RedactableSpan, mode: RedactorMode = 'client'): exit } } - -// Test-only introspection: lets tests verify the rule set without re-deriving it. - -export const _internalsForTests = { - PROVIDER_PATTERNS, - CLIENT_ATTR_BLOCKLIST, - SERVER_ATTR_BLOCKLIST_EXTRA, - LABELED_KV, - URL_USERINFO, - ENV_LINE -} diff --git a/src/main/opencode-usage/opencode-usage-provider.ts b/src/main/opencode-usage/opencode-usage-provider.ts new file mode 100644 index 00000000000..93af71aa9c7 --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-provider.ts @@ -0,0 +1,23 @@ +import type { UsageProvider } from '../usage/usage-provider-contract' +import { scanOpenCodeUsageDatabases } from './scanner' +import type { + OpenCodeUsageDailyAggregate, + OpenCodeUsagePersistedDatabase, + OpenCodeUsageSession +} from './types' + +// Why: v2 adds per-database session ownership (stale sibling-copy dedupe). +// Older caches were built without it and can carry doubled sessions (#8006). +export const OPENCODE_USAGE_SCHEMA_VERSION = 2 + +export const openCodeUsageProvider = { + id: 'opencode', + label: 'OpenCode', + schemaVersion: OPENCODE_USAGE_SCHEMA_VERSION, + scan: scanOpenCodeUsageDatabases +} satisfies UsageProvider< + 'processedDatabases', + OpenCodeUsagePersistedDatabase, + OpenCodeUsageSession, + OpenCodeUsageDailyAggregate +> diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index ec532560ade..b96e14be503 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -2,30 +2,29 @@ import { readdir, realpath, stat } from 'node:fs/promises' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import { yieldToEventLoop } from '../../shared/event-loop-yield' -import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' +import { createUsageEventAggregation } from '../usage/usage-event-aggregation' +import { + looksLikeWindowsPath, + normalizeComparablePath, + normalizeFsPath +} from '../usage/usage-path-comparison' +import { ensureNumber, extractString } from '../usage/usage-record-coercion' +import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, - OpenCodeUsageLocationBreakdown, - OpenCodeUsageLocationModelBreakdown, - OpenCodeUsageModelBreakdown, OpenCodeUsageParsedEvent, OpenCodeUsagePersistedDatabase, OpenCodeUsageProcessedDatabase, OpenCodeUsageSession } from './types' -export type OpenCodeUsageWorktreeRef = { - repoId: string - worktreeId: string - path: string - displayName: string -} +export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef type OpenCodeUsageRow = { id: string @@ -57,28 +56,6 @@ type OpenCodeSessionUsageRow = { const YIELD_EVERY_DATABASES = 2 -function ensureNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : 0 -} - -function normalizeComparablePath(pathValue: string, platform = process.platform): string { - const normalized = pathValue.replace(/\\/g, '/') - return platform === 'win32' || looksLikeWindowsPath(pathValue) - ? normalized.toLowerCase() - : normalized -} - -function normalizeFsPath(pathValue: string, platform = process.platform): string { - if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { - return win32.normalize(win32.resolve(pathValue)) - } - return posix.normalize(posix.resolve(pathValue)) -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') -} - type OpenCodeDatabaseOverride = { isConfigured: boolean path: string | null @@ -297,14 +274,6 @@ function parseJsonObject(value: unknown): Record | null { } } -function extractString(value: unknown): string | null { - if (typeof value !== 'string') { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} - function extractModelLabel(data: Record, sessionModel: unknown): string | null { const directModel = extractString(data.modelID) ?? extractString(data.modelId) const directProvider = extractString(data.providerID) ?? extractString(data.providerId) @@ -511,333 +480,24 @@ function addCost(left: number | null, right: number | null): number | null { return (left ?? 0) + (right ?? 0) } -function createEmptySession(event: OpenCodeUsageAttributedEvent): OpenCodeUsageSession { - return { - sessionId: event.sessionId, - firstTimestamp: event.timestamp, - lastTimestamp: event.timestamp, - primaryModel: event.model, - hasMixedModels: false, - primaryProjectLabel: event.projectLabel, - hasMixedLocations: false, - primaryWorktreeId: event.worktreeId, - primaryRepoId: event.repoId, - eventCount: 0, - totalInputTokens: 0, - totalCachedInputTokens: 0, - totalOutputTokens: 0, - totalReasoningOutputTokens: 0, - totalTokens: 0, - estimatedCostUsd: null, - locationBreakdown: [], - modelBreakdown: [], - locationModelBreakdown: [] - } -} +type OpenCodeUsageMetric = { estimatedCostUsd: number | null } -function createEmptyDailyAggregate( - event: OpenCodeUsageAttributedEvent -): OpenCodeUsageDailyAggregate { - return { - day: event.day, - model: event.model, - projectKey: event.projectKey, - projectLabel: event.projectLabel, - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 0, - inputTokens: 0, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - estimatedCostUsd: null - } -} - -function mergeLocationBreakdown( - target: OpenCodeUsageLocationBreakdown[], - event: OpenCodeUsageAttributedEvent -): void { - const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) - return - } - - target.push({ - locationKey: event.projectKey, - projectLabel: event.projectLabel, - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - estimatedCostUsd: event.estimatedCostUsd - }) -} - -function mergeModelBreakdown( - target: OpenCodeUsageModelBreakdown[], - event: OpenCodeUsageAttributedEvent -): void { - const key = event.model ?? 'unknown' - const existing = target.find((entry) => entry.modelKey === key) ?? null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) - return - } - - target.push({ - modelKey: key, - modelLabel: event.model ?? 'Unknown model', - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - estimatedCostUsd: event.estimatedCostUsd - }) -} - -function mergeLocationModelBreakdown( - target: OpenCodeUsageLocationModelBreakdown[], - event: OpenCodeUsageAttributedEvent -): void { - const modelKey = event.model ?? 'unknown' - const existing = - target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ?? - null - if (existing) { - existing.eventCount++ - existing.inputTokens += event.inputTokens - existing.cachedInputTokens += event.cachedInputTokens - existing.outputTokens += event.outputTokens - existing.reasoningOutputTokens += event.reasoningOutputTokens - existing.totalTokens += event.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, event.estimatedCostUsd) - return - } - - target.push({ - locationKey: event.projectKey, - modelKey, - modelLabel: event.model ?? 'Unknown model', - repoId: event.repoId, - worktreeId: event.worktreeId, - eventCount: 1, - inputTokens: event.inputTokens, - cachedInputTokens: event.cachedInputTokens, - outputTokens: event.outputTokens, - reasoningOutputTokens: event.reasoningOutputTokens, - totalTokens: event.totalTokens, - estimatedCostUsd: event.estimatedCostUsd - }) -} - -function aggregateOpenCodeUsage(events: OpenCodeUsageAttributedEvent[]): { - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} { - const sessionsById = new Map() - const dailyByKey = new Map() - - for (const event of events) { - const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) - if (!sessionsById.has(event.sessionId)) { - sessionsById.set(event.sessionId, session) +const openCodeUsageAggregation = createUsageEventAggregation< + OpenCodeUsageAttributedEvent, + OpenCodeUsageMetric +>({ + metric: { + empty: () => ({ estimatedCostUsd: null }), + fromEvent: (event) => ({ estimatedCostUsd: event.estimatedCostUsd }), + fold: (target, source) => { + target.estimatedCostUsd = addCost(target.estimatedCostUsd, source.estimatedCostUsd) } - if (event.timestamp < session.firstTimestamp) { - session.firstTimestamp = event.timestamp - } - if (event.timestamp >= session.lastTimestamp) { - session.lastTimestamp = event.timestamp - } - session.eventCount++ - session.totalInputTokens += event.inputTokens - session.totalCachedInputTokens += event.cachedInputTokens - session.totalOutputTokens += event.outputTokens - session.totalReasoningOutputTokens += event.reasoningOutputTokens - session.totalTokens += event.totalTokens - session.estimatedCostUsd = addCost(session.estimatedCostUsd, event.estimatedCostUsd) - mergeLocationBreakdown(session.locationBreakdown, event) - mergeModelBreakdown(session.modelBreakdown, event) - mergeLocationModelBreakdown(session.locationModelBreakdown, event) + }, + cloneSessionForMerge: (session) => structuredClone(session) +}) - const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::') - const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) - if (!dailyByKey.has(dailyKey)) { - dailyByKey.set(dailyKey, daily) - } - daily.eventCount++ - daily.inputTokens += event.inputTokens - daily.cachedInputTokens += event.cachedInputTokens - daily.outputTokens += event.outputTokens - daily.reasoningOutputTokens += event.reasoningOutputTokens - daily.totalTokens += event.totalTokens - daily.estimatedCostUsd = addCost(daily.estimatedCostUsd, event.estimatedCostUsd) - } - - return { - sessions: finalizeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) - } -} - -function finalizeSessions(sessionsById: Map): OpenCodeUsageSession[] { - for (const session of sessionsById.values()) { - session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) - session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) - const primaryLocation = session.locationBreakdown[0] ?? null - const primaryModel = session.modelBreakdown[0] ?? null - session.primaryProjectLabel = - session.locationBreakdown.length <= 1 - ? (primaryLocation?.projectLabel ?? 'Unknown location') - : 'Multiple locations' - session.hasMixedLocations = session.locationBreakdown.length > 1 - session.primaryWorktreeId = primaryLocation?.worktreeId ?? null - session.primaryRepoId = primaryLocation?.repoId ?? null - session.primaryModel = - session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' - session.hasMixedModels = session.modelBreakdown.length > 1 - } - - return [...sessionsById.values()].sort((left, right) => - right.lastTimestamp.localeCompare(left.lastTimestamp) - ) -} - -function mergeSessions( - target: Map, - sessions: OpenCodeUsageSession[] -): void { - for (const session of sessions) { - const existing = target.get(session.sessionId) - if (!existing) { - target.set(session.sessionId, structuredClone(session)) - continue - } - - existing.firstTimestamp = - session.firstTimestamp < existing.firstTimestamp - ? session.firstTimestamp - : existing.firstTimestamp - existing.lastTimestamp = - session.lastTimestamp > existing.lastTimestamp - ? session.lastTimestamp - : existing.lastTimestamp - existing.eventCount += session.eventCount - existing.totalInputTokens += session.totalInputTokens - existing.totalCachedInputTokens += session.totalCachedInputTokens - existing.totalOutputTokens += session.totalOutputTokens - existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens - existing.totalTokens += session.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, session.estimatedCostUsd) - - for (const location of session.locationBreakdown) { - const existingLocation = - existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? - null - if (existingLocation) { - existingLocation.eventCount += location.eventCount - existingLocation.inputTokens += location.inputTokens - existingLocation.cachedInputTokens += location.cachedInputTokens - existingLocation.outputTokens += location.outputTokens - existingLocation.reasoningOutputTokens += location.reasoningOutputTokens - existingLocation.totalTokens += location.totalTokens - existingLocation.estimatedCostUsd = addCost( - existingLocation.estimatedCostUsd, - location.estimatedCostUsd - ) - } else { - existing.locationBreakdown.push({ ...location }) - } - } - - for (const model of session.modelBreakdown) { - const existingModel = - existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null - if (existingModel) { - existingModel.eventCount += model.eventCount - existingModel.inputTokens += model.inputTokens - existingModel.cachedInputTokens += model.cachedInputTokens - existingModel.outputTokens += model.outputTokens - existingModel.reasoningOutputTokens += model.reasoningOutputTokens - existingModel.totalTokens += model.totalTokens - existingModel.estimatedCostUsd = addCost( - existingModel.estimatedCostUsd, - model.estimatedCostUsd - ) - } else { - existing.modelBreakdown.push({ ...model }) - } - } - - for (const locationModel of session.locationModelBreakdown) { - const existingLocationModel = - existing.locationModelBreakdown.find( - (entry) => - entry.locationKey === locationModel.locationKey && - entry.modelKey === locationModel.modelKey - ) ?? null - if (existingLocationModel) { - existingLocationModel.eventCount += locationModel.eventCount - existingLocationModel.inputTokens += locationModel.inputTokens - existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens - existingLocationModel.outputTokens += locationModel.outputTokens - existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens - existingLocationModel.totalTokens += locationModel.totalTokens - existingLocationModel.estimatedCostUsd = addCost( - existingLocationModel.estimatedCostUsd, - locationModel.estimatedCostUsd - ) - } else { - existing.locationModelBreakdown.push({ ...locationModel }) - } - } - } -} - -function mergeDailyAggregates( - target: Map, - dailyAggregates: OpenCodeUsageDailyAggregate[] -): void { - for (const aggregate of dailyAggregates) { - const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') - const existing = target.get(key) - if (!existing) { - target.set(key, { ...aggregate }) - continue - } - existing.eventCount += aggregate.eventCount - existing.inputTokens += aggregate.inputTokens - existing.cachedInputTokens += aggregate.cachedInputTokens - existing.outputTokens += aggregate.outputTokens - existing.reasoningOutputTokens += aggregate.reasoningOutputTokens - existing.totalTokens += aggregate.totalTokens - existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, aggregate.estimatedCostUsd) - } -} +const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregates } = + openCodeUsageAggregation export async function parseOpenCodeUsageDatabase( dbPath: string, @@ -874,7 +534,7 @@ export async function parseOpenCodeUsageDatabase( } return { ...processedDatabase, - ...aggregateOpenCodeUsage(events), + ...openCodeUsageAggregation.aggregate(events), ownedSessionIds: [...claimedBySessionId.entries()] .filter(([, owned]) => owned) .map(([sessionId]) => sessionId), @@ -1006,28 +666,6 @@ export async function scanOpenCodeUsageDatabases( return { processedDatabases, sessions: finalizeSessions(sessionsById), - dailyAggregates: [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) + dailyAggregates: sortDailyAggregates(dailyByKey) } } - -export function createWorktreeRefs( - repos: Repo[], - worktreesByRepo: Map -): OpenCodeUsageWorktreeRef[] { - const refs: OpenCodeUsageWorktreeRef[] = [] - for (const repo of repos) { - for (const worktree of worktreesByRepo.get(repo.id) ?? []) { - refs.push({ - repoId: repo.id, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - } - } - return refs -} diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 44b03eec4e0..6f3759efa7f 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -51,7 +51,6 @@ vi.mock('node:fs/promises', async () => { }) vi.mock('./scanner', () => ({ - createWorktreeRefs: vi.fn(() => []), scanOpenCodeUsageDatabases: vi.fn() })) diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index 67080d6db2d..c85fa519ba5 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -17,11 +17,10 @@ import type { import type { Store } from '../persistence' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' +import { createWorktreeRefs } from '../usage/usage-worktree-refs' +import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' -// Why: v2 adds per-database session ownership (stale sibling-copy dedupe). -// Older caches were built without it and can carry doubled sessions (#8006). -const SCHEMA_VERSION = 2 +const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -253,7 +252,7 @@ export class OpenCodeUsageStore { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanOpenCodeUsageDatabases( + const result = await openCodeUsageProvider.scan( createWorktreeRefs(repos, worktreesByRepo), this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedDatabases diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 32781775a87..706b39151d9 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -25,7 +25,7 @@ type OpenCodeOverlayManifest = { pluginEntries: string[] } -// Why: bounds-check only — the id is a daemon sessionId with path separators, hashed downstream to a filesystem-safe name (an old regex rejecting "/"/":" broke every such id, #1148); 1024 just caps pathological hash input. +// Why: session IDs may contain path separators and are hashed downstream; cap pathological input. function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } @@ -40,7 +40,7 @@ export function getOpenCodePluginSource(): string { } export function getOpenCodeFamilyPluginSource(hookPathname: string): string { - // Why: plugin runs in OpenCode's Node process and POSTs Orca's ORCA_* PTY env to the shared agent-hooks server; events are mapped plugin-side to fit the server's per-case switch. + // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', "// endpoint file does not spam OpenCode's stderr once per hook post.", diff --git a/src/main/orca-profiles/profile-persistence-deadline.ts b/src/main/orca-profiles/profile-persistence-deadline.ts new file mode 100644 index 00000000000..084a53c40fe --- /dev/null +++ b/src/main/orca-profiles/profile-persistence-deadline.ts @@ -0,0 +1,21 @@ +import type { Store } from '../persistence' + +const PROFILE_PERSISTENCE_TIMEOUT_MS = 20_000 + +export async function flushActiveProfileBeforeFileMutation(store: Store): Promise { + const controller = new AbortController() + let timeout: ReturnType | null = null + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + controller.abort() + reject(new Error('orca_profile_persistence_timeout')) + }, PROFILE_PERSISTENCE_TIMEOUT_MS) + }) + try { + await Promise.race([store.flushPendingOrThrowAsync({ signal: controller.signal }), deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts index 6a4491e8b38..2b3a6ea68b1 100644 --- a/src/main/persistence-async-write-syscalls.test.ts +++ b/src/main/persistence-async-write-syscalls.test.ts @@ -1,9 +1,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + utimesSync, + writeFileSync +} from 'node:fs' import type * as NodeFs from 'node:fs' import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import type { SshRemotePtyLeaseState } from '../shared/ssh-types' const testState = { dir: '' } @@ -130,8 +139,38 @@ const ROTATION_INTERLEAVE_CASES = [ type TestStore = { updateUI(updates: { sidebarWidth: number }): void + setGitHubCache(cache: { pr: Record; issue: Record }): void waitForPendingWrite(): Promise flushOrThrow(): void + flushPendingAsync(): Promise + flushPendingOrThrowAsync(): Promise + upsertSshPtyConsumerRecovery(record: { + targetId: string + clientInstanceId: string + serverBuildId: string + clientGeneration: number + ownerGeneration: number + ownerLease: string + }): Promise + removeSshPtyConsumerRecovery(targetId: string): Promise + upsertSshRemotePtyLease(lease: { + targetId: string + ptyId: string + state: SshRemotePtyLeaseState + }): void + markSshRemotePtyLeasesAsync(targetId: string, state: SshRemotePtyLeaseState): Promise + markSshRemotePtyLeasesAttachedAsync(targetId: string, ptyIds: readonly string[]): Promise +} + +function consumerRecovery(clientInstanceId: string) { + return { + targetId: 'ssh-1', + clientInstanceId, + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + } } async function createStore(dir: string): Promise { @@ -309,7 +348,173 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { expect(ring['orca-data.json.bak.2']).toBeUndefined() }) - it('keeps the due check inside ownership when a second writer starts', async () => { + it('a sync checkpoint vetoes an async write already parked on rename', async () => { + const dir = makeDir() + const store = await createStore(dir) + let releaseRename!: () => void + const renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + let signalRename!: () => void + const renameStarted = new Promise((resolve) => { + signalRename = resolve + }) + fsCalls.waitAsync = (fn, target) => { + if (fn !== 'rename' || target === dataFile(dir) || !target.startsWith(dataFile(dir))) { + return null + } + signalRename() + return renameRelease + } + + fsCalls.dirPrefix = dir + fsCalls.recording = true + store.updateUI({ sidebarWidth: 501 }) + vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) + const pending = store.waitForPendingWrite() + await renameStarted + + store.updateUI({ sidebarWidth: 502 }) + store.flushOrThrow() + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) + + releaseRename() + await pending + fsCalls.recording = false + fsCalls.waitAsync = null + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(502) + expect(readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + }) + + it('retries a genuine ENOENT instead of marking the state persisted', async () => { + const dir = makeDir() + const store = await createStore(dir) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.dirPrefix = dir + fsCalls.recording = true + fsCalls.failAsync = (fn, target) => + fn === 'rename' && target.startsWith(dataFile(dir)) && !target.includes('.bak.') + ? Object.assign(new Error('mount disappeared'), { code: 'ENOENT' }) + : null + + store.updateUI({ sidebarWidth: 511 }) + await store.flushPendingAsync() + expect(existsSync(dataFile(dir))).toBe(false) + + fsCalls.failAsync = null + await store.flushPendingAsync() + fsCalls.recording = false + errors.mockRestore() + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(511) + }) + + it('the throwing async barrier drains mutations made during its write', async () => { + const dir = makeDir() + const store = await createStore(dir) + let releaseRename!: () => void + const renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + let signalRename!: () => void + const renameStarted = new Promise((resolve) => { + signalRename = resolve + }) + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { + return null + } + held = true + signalRename() + return renameRelease + } + fsCalls.dirPrefix = dir + fsCalls.recording = true + + store.updateUI({ sidebarWidth: 601 }) + const barrier = store.flushPendingOrThrowAsync() + await renameStarted + store.updateUI({ sidebarWidth: 602 }) + releaseRename() + await barrier + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(602) + }) + + it('bounds a best-effort flush to one state generation', async () => { + const dir = makeDir() + const store = await createStore(dir) + let releaseRename!: () => void + const renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + let signalRename!: () => void + const renameStarted = new Promise((resolve) => { + signalRename = resolve + }) + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'rename' || !target.startsWith(dataFile(dir))) { + return null + } + held = true + signalRename() + return renameRelease + } + fsCalls.dirPrefix = dir + fsCalls.recording = true + + store.updateUI({ sidebarWidth: 621 }) + const flush = store.flushPendingAsync() + await renameStarted + store.updateUI({ sidebarWidth: 622 }) + releaseRename() + await flush + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(621) + + await store.flushPendingOrThrowAsync() + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(622) + }) + + it('the throwing async barrier drains mutations made during sidecar I/O', async () => { + const dir = makeDir() + const store = await createStore(dir) + let releaseRename!: () => void + const renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + let signalRename!: () => void + const renameStarted = new Promise((resolve) => { + signalRename = resolve + }) + fsCalls.waitAsync = (fn, target) => { + if (fn !== 'rename' || !target.includes('orca-github-cache.json.')) { + return null + } + signalRename() + return renameRelease + } + fsCalls.dirPrefix = dir + fsCalls.recording = true + + store.updateUI({ sidebarWidth: 611 }) + store.setGitHubCache({ pr: {}, issue: {} }) + const barrier = store.flushPendingOrThrowAsync() + await renameStarted + store.updateUI({ sidebarWidth: 612 }) + releaseRename() + await barrier + fsCalls.recording = false + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(612) + }) + + it('serializes a second writer behind the owned rotation', async () => { const dir = makeDir() const store = await createStore(dir) seedStaleBackup(dir) @@ -340,22 +545,26 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { store.updateUI({ sidebarWidth: 371 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) const firstWrite = store.waitForPendingWrite() + let allWrites = firstWrite try { await rotationStarted store.updateUI({ sidebarWidth: 372 }) store.flushOrThrow() store.updateUI({ sidebarWidth: 373 }) vi.advanceTimersByTime(SAVE_DEBOUNCE_MS) - await store.waitForPendingWrite() + allWrites = store.waitForPendingWrite() expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1) + releaseRotation() + await Promise.all([firstWrite, allWrites]) } finally { releaseRotation() - await firstWrite + await allWrites fsCalls.recording = false fsCalls.waitAsync = null } const ring = ringSnapshot(dir) - expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json']) + expect(JSON.parse(ring['orca-data.json']).ui.sidebarWidth).toBe(373) + expect(JSON.parse(ring['orca-data.json.bak.0']).ui.sidebarWidth).toBe(372) expect(ring['orca-data.json.bak.1']).toBe(staleBackup) expect(ring['orca-data.json.bak.2']).toBeUndefined() }) @@ -475,6 +684,201 @@ describe('async persistence write path avoids synchronous fs syscalls', () => { expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`) }) + it('persists SSH PTY consumer recovery without a sync syscall, durable once awaited', async () => { + const dir = makeDir() + const store = await createStore(dir) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toEqual([]) + // Durability is awaited, not merely debounced: the record is on disk when the promise resolves. + const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries).toHaveLength(1) + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + }) + + it('rejects the consumer-recovery durability barrier when the primary write fails', async () => { + const dir = makeDir() + const store = await createStore(dir) + const writeError = Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + fsCalls.dirPrefix = dir + fsCalls.recording = true + fsCalls.failAsync = (fn, target) => + fn === 'open' && target.startsWith(`${dataFile(dir)}.`) ? writeError : null + + try { + await expect(store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1'))).rejects.toBe( + writeError + ) + } finally { + fsCalls.recording = false + errors.mockRestore() + } + }) + + it('removes SSH PTY consumer recovery without a sync syscall, durable once awaited', async () => { + const dir = makeDir() + const store = await createStore(dir) + await store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + await store.removeSshPtyConsumerRecovery('ssh-1') + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toEqual([]) + const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { + sshPtyConsumerRecoveries: unknown[] + } + expect(persisted.sshPtyConsumerRecoveries).toEqual([]) + }) + + it('persists failed-session lease detachment without a sync syscall', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'attached' }) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + await store.markSshRemotePtyLeasesAsync('ssh-1', 'detached') + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toEqual([]) + const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { + sshRemotePtyLeases: { state: string }[] + } + expect(persisted.sshRemotePtyLeases[0]?.state).toBe('detached') + }) + + it('persists selected reattach leases in one async write', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-2', state: 'expired' }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-3', state: 'detached' }) + // Why: a PTY that exits mid-reattach is terminated before the batch write lands; it must stay dead. + store.upsertSshRemotePtyLease({ targetId: 'ssh-1', ptyId: 'pty-4', state: 'terminated' }) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + await store.markSshRemotePtyLeasesAttachedAsync('ssh-1', ['pty-1', 'pty-2', 'pty-4']) + } finally { + fsCalls.recording = false + } + + expect(fsCalls.syncCalls).toEqual([]) + const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { + sshRemotePtyLeases: { ptyId: string; state: string }[] + } + expect(persisted.sshRemotePtyLeases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ptyId: 'pty-1', state: 'attached' }), + expect.objectContaining({ ptyId: 'pty-2', state: 'expired' }), + expect.objectContaining({ ptyId: 'pty-3', state: 'detached' }), + expect.objectContaining({ ptyId: 'pty-4', state: 'terminated' }) + ]) + ) + }) + + it('keeps async writers serialized across a synchronous shutdown flush', async () => { + const dir = makeDir() + const store = await createStore(dir) + let signalFirstOpen!: () => void + const firstOpen = new Promise((resolve) => { + signalFirstOpen = resolve + }) + let releaseFirstOpen!: () => void + const firstOpenRelease = new Promise((resolve) => { + releaseFirstOpen = resolve + }) + let held = false + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'open' || !target.endsWith('.tmp')) { + return null + } + held = true + signalFirstOpen() + return firstOpenRelease + } + + fsCalls.dirPrefix = dir + fsCalls.recording = true + try { + const firstWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + await firstOpen + store.flushOrThrow() + const secondWrite = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-2')) + await Promise.resolve() + await Promise.resolve() + + expect(fsCalls.asyncCalls.filter((call) => call.startsWith('open:'))).toHaveLength(1) + releaseFirstOpen() + await Promise.all([firstWrite, secondWrite]) + } finally { + releaseFirstOpen() + fsCalls.recording = false + fsCalls.waitAsync = null + } + + const persisted = JSON.parse(readFileSync(dataFile(dir), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-2') + }) + + it('lets a main-thread timer keep firing while a consumer-recovery write is in flight', async () => { + // The P1-A freeze itself: a stalled profile mount must not park the main thread on establish. + vi.useRealTimers() + const dir = makeDir() + const store = await createStore(dir) + const stallMs = 1_000 + // Why half: a sync write parks the loop for at least stallMs while the async path ticks every + // ~10ms, so this leaves room for scheduler jitter on a loaded runner without going vacuous. + const maxAcceptableGapMs = stallMs / 2 + + let lastTick = Date.now() + let worstGapMs = 0 + const heartbeat = setInterval(() => { + const now = Date.now() + worstGapMs = Math.max(worstGapMs, now - lastTick) + lastTick = now + }, 10) + + try { + fsCalls.dirPrefix = dir + fsCalls.stallMs = stallMs + fsCalls.recording = true + lastTick = Date.now() + const write = store.upsertSshPtyConsumerRecovery(consumerRecovery('client-1')) + // Why not await first: a fully synchronous write finishes before the interval can fire, so the + // heartbeat would never observe the stall it exists to detect. + await new Promise((resolve) => setTimeout(resolve, stallMs + 200)) + await write + } finally { + fsCalls.recording = false + clearInterval(heartbeat) + } + + expect(worstGapMs).toBeLessThan(maxAcceptableGapMs) + expect(readFileSync(dataFile(dir), 'utf-8')).toContain('client-1') + }, 20_000) + it('keeps the sync quit/crash fallback on synchronous syscalls', async () => { const dir = makeDir() const store = await createStore(dir) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 6cddc809c29..81faa5f6297 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -6800,7 +6800,7 @@ describe('Store', () => { it('durably encrypts SSH PTY consumer ownership for process restart recovery', async () => { const store = await createStore() store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 1 } as never }, issue: {} }) - store.upsertSshPtyConsumerRecovery({ + await store.upsertSshPtyConsumerRecovery({ targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', @@ -6858,7 +6858,7 @@ describe('Store', () => { port: 22, username: 'orca' }) - store.upsertSshPtyConsumerRecovery({ + await store.upsertSshPtyConsumerRecovery({ targetId: 'ssh-1', clientInstanceId: 'client-1', serverBuildId: 'relay-build-1', @@ -11699,6 +11699,197 @@ describe('Store host-partitioned workspace sessions', () => { expect(reloaded.getWorkspaceSession('runtime:env-a').activeRepoId).toBe('repo-a') }) + it('removes one orphaned worktree with a host-scoped topology fence', async () => { + const store = await createStore() + const worktreeId = 'repo-gone::/workspace/stale' + const session = { + ...makeHostSession('repo-gone'), + activeWorktreeId: worktreeId, + activeWorktreeIdsOnShutdown: [worktreeId], + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + }, + terminalTopologyRevisionByRepoId: { 'repo-gone': 3 } + } + store.setWorkspaceSession(session, 'local') + store.setWorkspaceSession(session, 'runtime:env-a') + store.setWorkspaceSession(session, 'runtime:env-b') + + store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a') + store.setWorkspaceSession(session, 'runtime:env-a') + store.flush() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession('runtime:env-a')).toMatchObject({ + tabsByWorktree: {}, + terminalTopologyRevisionByRepoId: { 'repo-gone': 4 } + }) + expect(reloaded.getWorkspaceSession('runtime:env-b')).toMatchObject({ + activeWorktreeId: worktreeId, + activeWorktreeIdsOnShutdown: [worktreeId], + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + terminalTopologyRevisionByRepoId: { 'repo-gone': 3 } + }) + // The local blob is a co-owner surface for every remote host, since the renderer parks state + // there whenever worktree ownership is unresolved; leaving it behind leaks the removed worktree. + expect(reloaded.getWorkspaceSession('local')).toMatchObject({ + tabsByWorktree: {}, + activeWorktreeId: null, + activeWorktreeIdsOnShutdown: [], + lastVisitedAtByWorktreeId: {}, + terminalTopologyRevisionByRepoId: { 'repo-gone': 4 } + }) + }) + + it('uses persisted worktree ownership when removing metadata', async () => { + const store = await createStore() + const worktreeId = 'repo-gone::/workspace/stale' + const session = { + ...makeHostSession('repo-gone'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + } + store.setWorkspaceSession(session, 'local') + store.setWorkspaceSession(session, 'runtime:env-a') + store.setWorkspaceSession(session, 'runtime:env-b') + store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + + store.removeWorktreeMeta(worktreeId) + + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() + // Only the owning host's partition and the local blob it may spill into are cleaned; a same-id + // worktree on another host is a different workspace and must survive. + expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1) + expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toBeUndefined() + }) + + it('cleans both surfaces that hold an ssh-owned worktree', async () => { + const store = await createStore() + const worktreeId = 'repo-ssh::/srv/stale' + const makeSession = (): ReturnType => ({ + ...makeHostSession('repo-ssh'), + activeWorktreeId: worktreeId, + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + }) + // The renderer persists SSH workspaces in the local blob; the main process writes their + // terminal state to the host's own `ssh:*` partition. Removal must clear both. + store.setWorkspaceSession(makeSession(), 'local') + store.setWorkspaceSession(makeSession(), 'ssh:conn-1') + store.setWorktreeMeta(worktreeId, { hostId: 'ssh:conn-1' }) + + store.removeWorktreeMeta(worktreeId, 'ssh:conn-1') + + for (const hostId of ['local', 'ssh:conn-1'] as const) { + expect(store.getWorkspaceSession(hostId)).toMatchObject({ + tabsByWorktree: {}, + activeWorktreeId: null, + lastVisitedAtByWorktreeId: {}, + terminalTopologyRevisionByRepoId: { 'repo-ssh': 1 } + }) + } + }) + + it('does not bump the topology fence in a partition that never held the worktree', async () => { + const store = await createStore() + const worktreeId = 'repo-split::/workspace/stale' + const otherWorktreeId = 'repo-split::/workspace/live' + // Why this matters: the fence is keyed by repo, so a bump here would make every later write + // for repo-split rebase onto main's copy and silently drop the live worktree's unsaved tabs. + store.setWorkspaceSession( + { + ...makeHostSession('repo-split'), + tabsByWorktree: { + [otherWorktreeId]: [makeTerminalTab({ id: 'live-tab', worktreeId: otherWorktreeId })] + } + }, + 'local' + ) + store.setWorkspaceSession( + { + ...makeHostSession('repo-split'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + }, + 'runtime:env-a' + ) + store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + + store.removeWorktreeMeta(worktreeId, 'local') + + expect( + store.getWorkspaceSession('runtime:env-a').terminalTopologyRevisionByRepoId?.['repo-split'] + ).toBe(1) + expect( + store.getWorkspaceSession('local').terminalTopologyRevisionByRepoId?.['repo-split'] + ).toBeUndefined() + expect(store.getWorkspaceSession('local').tabsByWorktree[otherWorktreeId]).toHaveLength(1) + }) + + it('trusts persisted ownership over a stale caller hostId', async () => { + const store = await createStore() + const worktreeId = 'repo-split::/workspace/stale' + const session = { + ...makeHostSession('repo-split'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + } + store.setWorkspaceSession(session, 'runtime:env-a') + store.setWorkspaceSession(session, 'runtime:env-b') + store.setWorktreeMeta(worktreeId, { hostId: 'runtime:env-a' }) + + // A caller's hostId comes from live routing and can go stale mid-removal; the same + // repoId::path can name a live worktree on env-b, whose tabs must survive. + store.removeWorktreeMeta(worktreeId, 'runtime:env-b') + + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() + expect(store.getWorkspaceSession('runtime:env-b').tabsByWorktree[worktreeId]).toHaveLength(1) + }) + + it('falls back to the caller hostId only when no ownership was recorded', async () => { + const store = await createStore() + const worktreeId = 'repo-gone::/workspace/stale' + const session = { + ...makeHostSession('repo-gone'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + } + store.setWorkspaceSession(session, 'runtime:env-a') + store.setWorkspaceSession(session, 'local') + + store.removeWorktreeMeta(worktreeId, 'runtime:env-a') + + expect(store.getWorkspaceSession('runtime:env-a').tabsByWorktree[worktreeId]).toBeUndefined() + expect(store.getWorkspaceSession('local').tabsByWorktree[worktreeId]).toBeUndefined() + }) + + // Trade-off: the fence is repo-wide, so claiming authority over a partition main never wrote would + // rebase every sibling worktree of that repo onto an empty copy. A delayed write wins here instead. + it('does not fence a delayed session write when its host partition was never persisted', async () => { + const store = await createStore() + const worktreeId = 'repo-gone::/workspace/stale' + const delayedSession = { + ...makeHostSession('repo-gone'), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'stale-tab', worktreeId })] + } + } + + store.removeWorkspaceSessionStateForWorktree(worktreeId, 'runtime:env-a') + store.setWorkspaceSession(delayedSession, 'runtime:env-a') + + const session = store.getWorkspaceSession('runtime:env-a') + expect(session.tabsByWorktree[worktreeId]?.[0]?.id).toBe('stale-tab') + expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined() + }) + it('drops a corrupt host partition to defaults without failing the others', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e5744973f12..40660eb8a1c 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -11,8 +11,13 @@ import { statSync, realpathSync } from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access } from 'node:fs/promises' -import { renameDurable, writeFileDurableSync } from './durable-file-write' +import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' +import { + durableWriteTempPath, + removeStaleDurableWriteTempFiles, + renameDurable, + writeFileDurableSync +} from './durable-file-write' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { createHash, randomUUID } from 'node:crypto' @@ -267,6 +272,10 @@ import { readTerminalScrollbackSnapshotSync, type TerminalScrollbackSnapshotStorage } from './terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from './terminal-scrollback-snapshot-async-migration' import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' @@ -372,6 +381,7 @@ function getGithubCacheFile(dataFile = getDataFile()): string { // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 +const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. @@ -2604,6 +2614,20 @@ function deleteScannedSessionFieldsForOwners( ) } +// Remote (ssh:/runtime:) workspace state can exist in both the renderer's local blob and main's host +// partition, because the renderer falls back to 'local' whenever worktree ownership is unresolved. +function workspaceSessionPartitionIdsForHost(hostId: string | null | undefined): ExecutionHostId[] { + const parsed = parseExecutionHostId(hostId) + return parsed && parsed.id !== LOCAL_EXECUTION_HOST_ID + ? [LOCAL_EXECUTION_HOST_ID, parsed.id] + : [LOCAL_EXECUTION_HOST_ID] +} + +/** The partition the host actually owns; the others are only spill surfaces for it. */ +function workspaceSessionOwnerPartitionForHost(hostId: string | null | undefined): ExecutionHostId { + return parseExecutionHostId(hostId)?.id ?? LOCAL_EXECUTION_HOST_ID +} + function removeWorkspaceSessionOwner( session: WorkspaceSessionState | undefined, ownerKey: string, @@ -2749,15 +2773,24 @@ export class Store { private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null + private pendingSnapshotFileWork: Promise | null = null + private readonly staleTempCleanup: Promise private writeGeneration = 0 + private inFlightAsyncTmpFile: string | null = null // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. private backupRotationInFlight = false // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. private writesFrozen = false + /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ + private quitFlushStarted = false + private quitFlushPromise: Promise | null = null // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null private githubCacheDirty = false + private githubCacheGeneration = 0 + private pendingGithubCacheWrite: Promise | null = null + private readonly staleGithubCacheTempCleanup: Promise private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< @@ -2772,6 +2805,13 @@ export class Store { constructor(options: StoreOptions = {}) { // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) this.terminalScrollbackSnapshotStorage = { @@ -3780,6 +3820,13 @@ export class Store { private static SAVE_MAX_WAIT_MS = 5_000 private scheduleSave(): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (this.quitFlushStarted) { + return + } + this.writeGeneration += 1 const now = Date.now() this.firstPendingSaveAt ??= now if (this.writeTimer) { @@ -3790,22 +3837,29 @@ export class Store { this.writeTimer = setTimeout(() => { this.writeTimer = null this.firstPendingSaveAt = null - // Why (issue #1158): serialize async writes so backup rotation can't race two callers over the same paths. - const prev = this.pendingWrite ?? Promise.resolve() - const next = prev - .then(() => this.writeToDiskAsync()) - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === next) { - this.pendingWrite = null - } - }) - this.pendingWrite = next + void this.enqueueWrite() }, delay) } + private enqueueWrite(): Promise { + const previousWrite = Promise.all([ + this.pendingWrite ?? this.staleTempCleanup, + this.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => this.writeToDiskAsync()) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (this.pendingWrite === trackedWrite) { + this.pendingWrite = null + } + }) + this.pendingWrite = trackedWrite + return write + } + /** Wait for any in-flight async disk write to complete. Used in tests. */ async waitForPendingWrite(): Promise { await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) @@ -3892,7 +3946,7 @@ export class Store { const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + const tmpFile = durableWriteTempPath(dataFile) // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. let renamed = false @@ -3909,10 +3963,21 @@ export class Store { if (this.writeGeneration !== gen) { return } - await renameDurable(tmpFile, dataFile) - renamed = true + this.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { + throw error + } + } finally { + if (this.inFlightAsyncTmpFile === tmpFile) { + this.inFlightAsyncTmpFile = null + } + } // Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it. - if (this.writeGeneration === gen) { + if (renamed && this.writeGeneration === gen) { this.lastWrittenStateHash = stateHash } } finally { @@ -3920,6 +3985,9 @@ export class Store { await rm(tmpFile).catch(() => {}) } } + if (!renamed) { + return + } // Why (#1158): rotate only after the primary rename while this write still owns its generation. if (this.writeGeneration !== gen) { return @@ -3968,6 +4036,9 @@ export class Store { } flushOrThrow(): void { + if (this.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null @@ -3976,7 +4047,18 @@ export class Store { const asyncWriteWasInFlight = this.pendingWrite !== null // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. this.writeGeneration++ - this.pendingWrite = null + if (this.inFlightAsyncTmpFile) { + try { + unlinkSync(this.inFlightAsyncTmpFile) + this.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void this.enqueueWrite().catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. this.writeToDiskSync({ force: asyncWriteWasInFlight, skipBackupRotation: this.backupRotationInFlight @@ -5264,15 +5346,34 @@ export class Store { return updated } - removeWorktreeMeta(worktreeId: string): void { + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // Persisted ownership beats stale live routing; hostId is only an ownerless fallback. + const owner = this.state.worktreeMeta[worktreeId]?.hostId ?? hostId + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter((partition) => + this.hasPersistedWorkspaceSession(partition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + this.partitionOwnsWorktreeTabs(worktreeId, partition) || + (partition === ownerPartition && + !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) + ) + ) delete this.state.worktreeMeta[worktreeId] delete this.state.worktreeLineageById[worktreeId] delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - worktreeId, - { advanceTerminalTopologyRevision: true } - )! + for (const partition of partitions) { + this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + }) + } this.scheduleSave() } @@ -6021,6 +6122,7 @@ export class Store { // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. this.state.githubCache = cache this.githubCacheDirty = true + this.githubCacheGeneration += 1 } // ── Workspace Session ───────────────────────────────────────────── @@ -6038,6 +6140,14 @@ export class Store { return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() } + /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ + private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + this.state.workspaceSessionsByHostId?.[hostId] !== undefined + ) + } + getWorkspaceSessionHostIds(): ExecutionHostId[] { const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { @@ -6067,6 +6177,70 @@ export class Store { this.setHostWorkspaceSession(resolved, session) } + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) + } + } + + private removeWorkspaceSessionOwnerInPartition( + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } + ): void { + if (!this.hasPersistedWorkspaceSession(resolved)) { + return + } + const current = this.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + this.state.workspaceSessionsByHostId = { + ...this.state.workspaceSessionsByHostId, + [resolved]: session + } + } + this.scheduleSave() + } + + /** Whether a partition still holds terminal membership for `worktreeId`. */ + private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { + return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined + } + + /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ + private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = this.resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this.setLocalWorkspaceSession(session, true) + return + } + this.setHostWorkspaceSession(resolved, session) + } + /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. @@ -6084,7 +6258,10 @@ export class Store { this.scheduleSave() } - private setLocalWorkspaceSession(session: PersistedState['workspaceSession']): void { + private setLocalWorkspaceSession( + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false + ): void { const prior = this.state.workspaceSession session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) session = pruneWorkspaceSessionBrowserHistory( @@ -6213,16 +6390,76 @@ export class Store { } } session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots(prior, session, this.terminalScrollbackSnapshotStorage) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + this.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + this.terminalScrollbackSnapshotStorage + ) + } this.state.workspaceSession = session + if (deferSnapshotFiles) { + this.enqueueTerminalScrollbackSnapshotWork(prior, session) + } this.scheduleSave() } + private enqueueTerminalScrollbackSnapshotWork( + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState + ): void { + const previous = this.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (this.state.workspaceSession !== staged) { + if (this.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + this.state.workspaceSession, + this.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + this.terminalScrollbackSnapshotStorage + ) + const current = + this.state.workspaceSession === staged ? migrated : this.state.workspaceSession + if (this.state.workspaceSession === staged) { + this.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + this.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + this.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (this.pendingSnapshotFileWork === work) { + this.pendingSnapshotFileWork = null + } + }) + this.pendingSnapshotFileWork = work + } + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { const resolved = this.resolveHostId(hostId) // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. @@ -6745,7 +6982,7 @@ export class Store { return record ? structuredClone(record) : null } - upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): void { + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { const normalized = normalizeSshPtyConsumerRecovery(record) if (!normalized) { throw new Error('Invalid SSH PTY consumer recovery record') @@ -6755,26 +6992,24 @@ export class Store { ...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId), normalized ] - this.flushSshPtyConsumerRecovery() + await this.flushSshPtyConsumerRecovery() } - removeSshPtyConsumerRecovery(targetId: string): void { + async removeSshPtyConsumerRecovery(targetId: string): Promise { const recoveries = this.state.sshPtyConsumerRecoveries ?? [] const next = recoveries.filter((record) => record.targetId !== targetId) if (next.length === recoveries.length) { return } this.state.sshPtyConsumerRecoveries = next - this.flushSshPtyConsumerRecovery() + await this.flushSshPtyConsumerRecovery() } - private flushSshPtyConsumerRecovery(): void { - try { - // Why: ownership must be durable before relay setup continues, but active-view and GitHub sidecars are unrelated startup work. - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush SSH PTY consumer recovery:', err) - } + private async flushSshPtyConsumerRecovery(): Promise { + // Why: ownership must be durable before relay setup continues, but this runs on the live + // establish/reconnect path — a sync flush would park the main thread on a stalled profile mount. + // Why not caught here: the failure must reach the awaiting caller. + await this.flushDurableStateOrThrowAsync() } // ── SSH Remote PTY Leases ────────────────────────────────────────── @@ -6819,13 +7054,47 @@ export class Store { } markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + if (this.updateSshRemotePtyLeaseStates(targetId, state)) { + this.flush() + } + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + if (this.updateSshRemotePtyLeaseStates(targetId, state)) { + await this.flushDurableStateOrThrowAsync() + } + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + const relayPtyIds = new Set( + ptyIds.map((ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId)) + ) + if (this.updateSshRemotePtyLeaseStates(targetId, 'attached', relayPtyIds)) { + await this.flushDurableStateOrThrowAsync() + } + } + + private updateSshRemotePtyLeaseStates( + targetId: string, + state: SshRemotePtyLease['state'], + ptyIds?: ReadonlySet + ): boolean { const now = Date.now() let changed = false const shouldClearBindings = state === 'terminated' || state === 'expired' const leasesToClear: SshRemotePtyLease[] = [] this.state.sshRemotePtyLeases ??= [] for (const lease of this.state.sshRemotePtyLeases) { - if (lease.targetId !== targetId) { + if (lease.targetId !== targetId || (ptyIds && !ptyIds.has(lease.ptyId))) { + continue + } + if (state === 'attached' && (lease.state === 'terminated' || lease.state === 'expired')) { continue } if (state === 'detached' && lease.state !== 'attached') { @@ -6848,9 +7117,7 @@ export class Store { const bindingsChanged = shouldClearBindings ? this.clearSshRemotePtyBindingsForLeases(targetId, leasesToClear) : false - if (changed || bindingsChanged) { - this.flush() - } + return changed || bindingsChanged } markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { @@ -6984,6 +7251,9 @@ export class Store { // ── Flush (for shutdown) ─────────────────────────────────────────── flush(): void { + if (this.quitFlushStarted) { + return + } try { this.flushOrThrow() } catch (err) { @@ -6997,6 +7267,147 @@ export class Store { this.writeGithubCacheSnapshotSync() } + /** + * Async twin of flush() for the quit path. + * + * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron + * main thread. On a stalled network profile mount that syscall is uninterruptible, so the + * app stops repainting and Force Quit stops working — and no main-thread deadline can + * bound it, because the deadline's own timer is stuck behind the same block. + * + * Never throws — it joins the quit teardown barrier, where a rejection is noise. + */ + flushAsync(): Promise { + if (this.quitFlushPromise) { + return this.quitFlushPromise + } + this.quitFlushStarted = true + this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) + return this.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync(options: { signal?: AbortSignal } = {}): Promise { + if (this.writesFrozen || this.quitFlushStarted) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return this.flushCurrentStateAsync(false, options.signal) + } + + // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are + // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. + private async flushDurableStateOrThrowAsync(): Promise { + if (this.writesFrozen || this.quitFlushStarted) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + this.firstPendingSaveAt = null + const generation = this.writeGeneration + await this.enqueueWrite() + if (generation === this.writeGeneration) { + break + } + } + } + + private async flushCurrentStateAsync( + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true + ): Promise { + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + this.firstPendingSaveAt = null + const generation = this.writeGeneration + try { + await this.enqueueWrite() + } catch (error) { + await (final + ? this.activeViewPreference.flushAsync() + : this.activeViewPreference.flushPendingAsync(signal)) + await this.writeGithubCacheSnapshotAsync(final, signal) + throw error + } + await (final + ? this.activeViewPreference.flushAsync() + : this.activeViewPreference.flushPendingAsync(signal)) + await this.writeGithubCacheSnapshotAsync(final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration || generation === this.writeGeneration) { + break + } + } + } + + // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. + private async writeGithubCacheSnapshotAsync( + drainToStableGeneration = true, + signal?: AbortSignal + ): Promise { + if (!this.githubCacheDirty) { + return + } + const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (this.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = this.githubCacheGeneration + const cacheFile = getGithubCacheFile(this.dataFile) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') + if (generation === this.githubCacheGeneration) { + await rename(tmpFile, cacheFile) + renamed = true + if (generation === this.githubCacheGeneration) { + this.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (this.pendingGithubCacheWrite === nextWrite) { + this.pendingGithubCacheWrite = null + } + }) + this.pendingGithubCacheWrite = nextWrite + await nextWrite + } + // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { this.writesFrozen = true @@ -7011,12 +7422,19 @@ export class Store { if (!this.githubCacheDirty) { return } + if (this.pendingGithubCacheWrite) { + void this.writeGithubCacheSnapshotAsync() + return + } const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = `${cacheFile}.${process.pid}.tmp` + const generation = this.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) try { writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) - this.githubCacheDirty = false + if (generation === this.githubCacheGeneration) { + this.githubCacheDirty = false + } } catch (err) { try { unlinkSync(tmpFile) diff --git a/src/main/pi/prefill-extension-source.ts b/src/main/pi/prefill-extension-source.ts index 054502c1c39..910c8cb796b 100644 --- a/src/main/pi/prefill-extension-source.ts +++ b/src/main/pi/prefill-extension-source.ts @@ -10,15 +10,6 @@ const PREFILL_ENV_VAR_BY_KIND: Record = { omp: 'ORCA_OMP_PREFILL' } -/** Pi's prefill env var. Exported for callers that need the literal name - * (renderer draft-launch plan builder, tests). OMP callers should read - * `ORCA_OMP_PREFILL_ENV_VAR` instead. */ -export const ORCA_PI_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.pi - -/** OMP's prefill env var. Mirrors `ORCA_PI_PREFILL_ENV_VAR` for OMP launches - * so renderer plans and shell-ready restore lines can stay agent-scoped. */ -export const ORCA_OMP_PREFILL_ENV_VAR = PREFILL_ENV_VAR_BY_KIND.omp - export function getPiPrefillExtensionSource(kind: PiAgentKind): string { const envVar = PREFILL_ENV_VAR_BY_KIND[kind] return [ diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 538d0a0be9d..66f491925ed 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -11,7 +11,6 @@ import { ORCA_PI_PREFILL_EXTENSION_FILE, getPiPrefillExtensionSource } from './prefill-extension-source' -export { ORCA_OMP_PREFILL_ENV_VAR, ORCA_PI_PREFILL_ENV_VAR } from './prefill-extension-source' import { ORCA_PI_EXTENSION_FILE, getPiTitlebarExtensionSource } from './titlebar-extension-source' import { isSafeDescendCandidate as sharedIsSafeDescendCandidate, diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts index eedeb42ef9f..af907f4a4ab 100644 --- a/src/main/plugins/plugin-content-safety.test.ts +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { disposeWatcherProcess } from '../ipc/parcel-watcher-process' import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' import { @@ -43,6 +44,9 @@ function manifest(overrides: ManifestOverrides = {}): PluginManifest { afterEach(async () => { vi.restoreAllMocks() + // Why: PluginService may still be releasing in-process Parcel watches; drop + // the shared vitest watcher state before deleting roots those watches own. + disposeWatcherProcess() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) diff --git a/src/main/plugins/plugin-dev-watcher.test.ts b/src/main/plugins/plugin-dev-watcher.test.ts index 77996fe3bd8..33bd0050625 100644 --- a/src/main/plugins/plugin-dev-watcher.test.ts +++ b/src/main/plugins/plugin-dev-watcher.test.ts @@ -47,6 +47,22 @@ describe('PluginDevWatcher', () => { await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) }) + it('contains unsubscribe rejections so dispose cannot fail the host process', async () => { + const unsubscribe = vi + .fn() + .mockRejectedValue(new Error('Unable to remove watcher: Invalid argument')) + const subscribePath = vi.fn(async () => ({ unsubscribe })) + const devWatcher = new PluginDevWatcher(subscribePath) + devWatcher.start(['/plugins/demo'], vi.fn()) + await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce()) + + expect(() => devWatcher.dispose()).not.toThrow() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + // Why: give the rejected promise a turn so an uncaught rejection would surface. + await Promise.resolve() + await Promise.resolve() + }) + it('does not spin refreshes when a missing path cannot be subscribed', async () => { vi.useFakeTimers() const subscribePath = vi.fn().mockRejectedValue(new Error('missing path')) diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts index ea77def417f..c6aefa12699 100644 --- a/src/main/plugins/plugin-dev-watcher.ts +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -20,6 +20,13 @@ const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption) } ) +// Why: Parcel unsubscribe rejects when the watch root is already gone (common +// in tests that rm temp dirs). Fire-and-forget callers must not leave that as +// an unhandled rejection that fails the Vitest process. +function releaseSubscription(subscription: WatcherProcessSubscription): void { + void subscription.unsubscribe().catch(() => undefined) +} + /** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */ export class PluginDevWatcher { private readonly subscriptions: WatcherProcessSubscription[] = [] @@ -40,7 +47,7 @@ export class PluginDevWatcher { failedBeforeReady = true if (subscription) { this.removeSubscription(subscription) - void subscription.unsubscribe() + releaseSubscription(subscription) } onWatcherError?.() this.scheduleRefresh(refresh) @@ -65,7 +72,7 @@ export class PluginDevWatcher { .then((created) => { subscription = created if (generation !== this.generation || failedBeforeReady) { - void created.unsubscribe() + releaseSubscription(created) return } this.subscriptions.push(created) @@ -85,7 +92,7 @@ export class PluginDevWatcher { this.refreshTimer = null } for (const subscription of this.subscriptions.splice(0)) { - void subscription.unsubscribe() + releaseSubscription(subscription) } } diff --git a/src/main/plugins/plugin-enablement.ts b/src/main/plugins/plugin-enablement.ts index 44349ef087b..e81a8c7e522 100644 --- a/src/main/plugins/plugin-enablement.ts +++ b/src/main/plugins/plugin-enablement.ts @@ -14,8 +14,6 @@ import { verifyInstructionalPluginContent } from './plugin-instructional-content * IPC handlers and the headless RPC methods both route through here. */ -export type PluginConsentDecision = PluginConsentRequest['decision'] - /** Records the user's consent-dialog answer. Approving stores the CURRENT * consent fingerprint and clears any disable; declining disables so the plugin * never re-prompts on later launches. */ diff --git a/src/main/providers/agent-foreground-process-pi.test.ts b/src/main/providers/agent-foreground-process-pi.test.ts new file mode 100644 index 00000000000..af63db24d42 --- /dev/null +++ b/src/main/providers/agent-foreground-process-pi.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot' +import { resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' +import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' + +describe('Pi Windows foreground recognition', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + resetProcessTableSnapshotForTests() + resetWindowsProcessRowsSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('recognizes the npm entrypoint within the active ConPTY', async () => { + const rows = JSON.stringify([ + { + CommandLine: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + ExecutablePath: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + Name: 'bash.exe', + ParentProcessId: 99, + ProcessId: 100 + }, + { + CommandLine: + 'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js', + ExecutablePath: 'C:\\Program Files\\nodejs\\node.exe', + Name: 'node.exe', + ParentProcessId: 100, + ProcessId: 101 + } + ]) + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _options: unknown, callback: unknown) => { + const done = callback as (error: null, result: { stdout: string; stderr: string }) => void + done(null, { + stdout: rows, + stderr: '' + }) + } + ) + const readWindowsConptyProcessIds = vi.fn(async () => new Set([100, 101])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'node.exe', { + fresh: true, + readWindowsConptyProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'pi' }) + expect(readWindowsConptyProcessIds).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index c3ad6f36103..39a00c823d7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -76,7 +76,7 @@ const PANE_IDENTITY_ENV_KEYS = [ let ptyCounter = 0 const ptyProcesses = new Map() const ptyIncarnations = new Map() -// Why: only agent sessions get descendant tree-kill (tool children run in detached groups SIGHUP can't reach); plain terminals skip it so nohup-detached children survive. +// Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. const ptyAgentSessionIds = new Set() // Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. type PtyShutdownOperation = { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 7fc84d85ca0..e3f06c563d6 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -63,12 +63,12 @@ function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) } -// Why: an inherited ZDOTDIR pointing at an Orca wrapper dir (`.../shell-ready/zsh`) makes the wrapper source itself recursively (zsh recursion limit); treat it as unset so the caller falls back to HOME. +// Why: an Orca wrapper ZDOTDIR recurses; treat it as unset and fall back to HOME. function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null { if (!value) { return null } - // Why: strip trailing slashes so `ZDOTDIR="$dir/"` still matches the self-loop suffix check; `/` collapses to empty → HOME fallback. + // Why: strip trailing slashes so wrapper paths match; `/` falls back to HOME. const normalized = value.replace(/\/+$/, '') if (!normalized || normalized.endsWith('/shell-ready/zsh')) { return null diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index f65a86aacea..bca3bf4fa18 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -145,17 +145,3 @@ export async function visitPtyProcessListingsInBatches( visit(listing.entry, listing.processes) } } - -export async function collectPtyProcessListings( - sources: Iterable, - load: (source: T) => Promise -): Promise { - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - await visitPtyProcessListingsInBatches(sources, load, (_source, listing) => { - for (const process of listing) { - processes.push(admission.admit(process)) - } - }) - return processes -} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a36b222f5e4..23c36859a1f 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -1,9 +1,6 @@ // Aggregate provider contract surface. The three per-domain contracts live in // their own files; this barrel keeps every historical `providers/types` import -// path working and owns the registry that ties them together. -import type { IPtyProvider } from './pty-provider-contract' -import type { IFilesystemProvider } from './filesystem-provider-contract' -import type { IGitProvider } from './git-provider-contract' +// path working. // ─── PTY Provider ─────────────────────────────────────────────────── @@ -31,12 +28,3 @@ export type { // ─── Git Provider ─────────────────────────────────────────────────── export type { GitProviderStatusOptions, IGitProvider } from './git-provider-contract' - -// ─── Provider Registry ────────────────────────────────────────────── - -/** Routes operations by connectionId; null/undefined selects the local provider. */ -export type IProviderRegistry = { - getPtyProvider(connectionId: string | null | undefined): IPtyProvider - getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider - getGitProvider(connectionId: string | null | undefined): IGitProvider -} diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts new file mode 100644 index 00000000000..35ebe7460c3 --- /dev/null +++ b/src/main/quit-path-durable-write-blocking.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, readFileSync, existsSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +// Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously, +// before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park +// the Electron main thread uninterruptibly — the app stops repainting and Force Quit stops +// working. The teardown deadline cannot rescue that, because its own timer needs the thread it +// would have to bound. The fix is to make the quit path awaitable, not to bound it. + +const testState = { dir: '' } + +const fsCalls = vi.hoisted(() => { + const calls = { + recording: false, + dirPrefix: '', + syncCalls: [] as string[], + /** Set to park every in-scope async fs call — a stalled mount, without stalling the thread. */ + holdAsync: null as Promise | null, + waitAsync: null as ((fn: string, target: string) => Promise | null) | null, + reset(): void { + calls.syncCalls.length = 0 + calls.holdAsync = null + calls.waitAsync = null + }, + inScope(target: unknown): target is string { + return ( + typeof target === 'string' && calls.dirPrefix !== '' && target.startsWith(calls.dirPrefix) + ) + }, + recordSync(fn: string, target: unknown): void { + if (calls.recording && calls.inScope(target)) { + calls.syncCalls.push(`${fn}:${target}`) + } + } + } + return calls +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of Object.keys(actual)) { + const original = (actual as unknown as Record)[name] + if (!name.endsWith('Sync') || typeof original !== 'function') { + continue + } + const fn = original as (...args: unknown[]) => unknown + const wrapper = (...args: unknown[]): unknown => { + fsCalls.recordSync(name, args[0]) + return fn(...args) + } + patched[name] = Object.assign(wrapper, fn) + } + return { ...patched, default: patched } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const patched: Record = { ...actual } + for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open', 'writeFile']) { + const fn = (actual as unknown as Record unknown>)[name] + patched[name] = async (...args: unknown[]): Promise => { + if (fsCalls.inScope(args[0]) && fsCalls.holdAsync) { + await fsCalls.holdAsync + } + if (fsCalls.inScope(args[0]) && typeof args[0] === 'string') { + await fsCalls.waitAsync?.(name, args[0]) + } + return fn(...args) + } + } + return { ...patched, default: patched } +}) + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length) + } +})) + +type TestStore = { + updateUI(updates: { sidebarWidth?: number; activeView?: string }): void + setGitHubCache(cache: unknown): void + waitForPendingWrite(): Promise + flushAsync(): Promise + flushOrThrow(): void + stageWorkspaceSessionBeforeUnload(session: Record): void +} + +type TestStatsCollector = { + onAgentStart(ptyId: string, at: number, repo?: string, worktree?: string): void + getSummary(): { totalAgentsSpawned: number; totalAgentTimeMs: number } + flushAsync(): Promise +} + +async function createStore(dir: string): Promise { + testState.dir = dir + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() as unknown as TestStore +} + +async function createStatsCollector(dir: string): Promise { + testState.dir = dir + vi.resetModules() + const { StatsCollector, initStatsPath } = await import('./stats/collector') + initStatsPath() + return new StatsCollector() as unknown as TestStatsCollector +} + +const dataFile = (dir: string): string => join(dir, 'orca-data.json') +const statsFile = (dir: string): string => join(dir, 'orca-stats.json') +const activeViewFile = (dir: string): string => join(dir, 'active-view.json') + +/** Resolves once the macrotask queue turns over — false if the main thread is parked. */ +function eventLoopTurned(): Promise { + return new Promise((resolve) => setTimeout(() => resolve(true), 0)) +} + +describe('quit-path durable writes never park the main thread', () => { + const dirs: string[] = [] + + function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-quit-path-')) + dirs.push(dir) + return dir + } + + beforeEach(() => { + fsCalls.recording = false + fsCalls.dirPrefix = '' + fsCalls.reset() + }) + + afterEach(() => { + vi.useRealTimers() + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('store.flushAsync() issues no synchronous fs syscalls', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 321 }) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + await store.flushAsync() + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(321) + }) + + it('renderer unload staging issues no synchronous fs syscalls', async () => { + const dir = makeDir() + const store = await createStore(dir) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + const leafId = '11111111-1111-4111-8111-111111111111' + store.stageWorkspaceSessionBeforeUnload({ + tabsByWorktree: { + 'remote-repo::/remote': [ + { + id: 'remote-tab', + ptyId: 'remote-pty', + worktreeId: 'remote-repo::/remote', + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'remote-tab': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + buffersByLeafId: { [leafId]: 'remote-scrollback' } + } + }, + openFilesByWorktree: {} + }) + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + await store.flushAsync() + const layout = JSON.parse(readFileSync(dataFile(dir), 'utf-8')).workspaceSession + .terminalLayoutsByTabId['remote-tab'] + const ref = layout.scrollbackRefsByLeafId[leafId] + expect(layout.buffersByLeafId).toBeUndefined() + expect(readFileSync(join(dir, 'terminal-scrollback', `${ref}.bin`), 'utf-8')).toBe( + 'remote-scrollback' + ) + }) + + it('store.flushAsync() also moves the active-view and github-cache sidecars off the thread', async () => { + const dir = makeDir() + const staleCacheTemp = join(dir, 'orca-github-cache.json.999999.1.orphan.tmp') + writeFileSync(staleCacheTemp, 'stale', 'utf-8') + const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000 + utimesSync(staleCacheTemp, staleSeconds, staleSeconds) + const store = await createStore(dir) + store.updateUI({ activeView: 'activity' }) + store.setGitHubCache({ pullRequestsByWorktree: {} }) + + fsCalls.dirPrefix = dir + fsCalls.recording = true + await store.flushAsync() + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('activity') + expect(existsSync(join(dir, 'orca-github-cache.json'))).toBe(true) + expect(existsSync(staleCacheTemp)).toBe(false) + }) + + it('stats.flushAsync() issues no synchronous fs syscalls', async () => { + const dir = makeDir() + const stats = await createStatsCollector(dir) + stats.onAgentStart('pty-1', 1_000, 'repo', 'worktree') + + fsCalls.dirPrefix = dir + fsCalls.recording = true + await stats.flushAsync() + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + expect(JSON.parse(readFileSync(statsFile(dir), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1) + }) + + it('stats.flushAsync() closes out live agents before it returns, not when its write lands', async () => { + // will-quit calls this before killAllPty(), which skips runtime.onPtyExit(). If the + // closeout moved behind the await, every agent still running at quit would lose its + // session time — the whole reason the flush is ordered ahead of the kill. + const dir = makeDir() + const stats = await createStatsCollector(dir) + stats.onAgentStart('pty-1', Date.now() - 4_000) + + fsCalls.dirPrefix = dir + fsCalls.holdAsync = new Promise(() => {}) + const pending = stats.flushAsync() + + expect(stats.getSummary().totalAgentTimeMs).toBeGreaterThan(0) + void pending + }) + + it('a stalled mount leaves the event loop live instead of parking it', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 654 }) + + fsCalls.dirPrefix = dir + // Every fs call from here on never resolves — the mount is gone. + fsCalls.holdAsync = new Promise(() => {}) + const pending = store.flushAsync() + + // The whole point: timers still fire, so the app still repaints and the quit + // deadline below can still be reached. A sync flush would have blocked here. + await expect(eventLoopTurned()).resolves.toBe(true) + void pending + }) + + it('the quit teardown deadline can now bound a wedged state flush', async () => { + const dir = makeDir() + const store = await createStore(dir) + const { settleTeardownWithinDeadline } = await import('./quit-teardown-deadline') + store.updateUI({ sidebarWidth: 999 }) + + fsCalls.dirPrefix = dir + fsCalls.holdAsync = new Promise(() => {}) + const pending = store.flushAsync() + + const outstanding = await settleTeardownWithinDeadline( + [{ name: 'state', promise: pending }], + 25 + ) + + expect(outstanding).toEqual(['state']) + // Cut short before the rename, so the previous file is still whole — bounded loss, no corruption. + expect(existsSync(dataFile(dir))).toBe(false) + }) + + it('store.flushAsync() drains an in-flight debounced write before writing', async () => { + vi.useFakeTimers() + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 100 }) + await vi.advanceTimersByTimeAsync(1_000) + + store.updateUI({ sidebarWidth: 200 }) + const flushed = store.flushAsync() + await vi.runAllTimersAsync() + await flushed + + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(200) + }) + + it('store.flushAsync() is one idempotent final barrier', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 777 }) + + const first = store.flushAsync() + const second = store.flushAsync() + + expect(second).toBe(first) + expect(() => store.flushOrThrow()).toThrow('final persistence') + await first + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(777) + }) + + it('serializes github-cache snapshots and keeps the newest generation', async () => { + const dir = makeDir() + const store = await createStore(dir) + let releaseWrite!: () => void + const writeRelease = new Promise((resolve) => { + releaseWrite = resolve + }) + let signalWrite!: () => void + const writeStarted = new Promise((resolve) => { + signalWrite = resolve + }) + let held = false + fsCalls.dirPrefix = dir + fsCalls.recording = true + fsCalls.waitAsync = (fn, target) => { + if (held || fn !== 'writeFile' || !target.includes('orca-github-cache.json')) { + return null + } + held = true + signalWrite() + return writeRelease + } + + store.setGitHubCache({ version: 1 }) + const finalFlush = store.flushAsync() + await writeStarted + store.setGitHubCache({ version: 2 }) + + releaseWrite() + await finalFlush + fsCalls.recording = false + + expect(JSON.parse(readFileSync(join(dir, 'orca-github-cache.json'), 'utf-8'))).toEqual({ + version: 2 + }) + }) + + it('stats.flushAsync() wins over a debounced write already in flight', async () => { + const dir = makeDir() + const stats = await createStatsCollector(dir) + stats.onAgentStart('pty-a', 1_000) + stats.onAgentStart('pty-b', 1_000) + + const inflight = (stats as unknown as { enqueueWrite(): Promise }).enqueueWrite.call( + stats + ) + await stats.flushAsync() + await inflight + + // Serialized writes plus the generation guard: the flush's fuller state survives. + const persisted = JSON.parse(readFileSync(statsFile(dir), 'utf-8')) + expect(persisted.aggregates.totalAgentsSpawned).toBe(2) + expect(persisted.aggregates.totalAgentTimeMs).toBeGreaterThan(0) + }) + + it('the quit flush is the last write — later mutations do not schedule another', async () => { + // A teardown step that touches the store would otherwise arm a debounced write with + // nothing awaiting it, leaving a rename to race the process exit. + vi.useFakeTimers() + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 10 }) + await store.flushAsync() + + store.updateUI({ sidebarWidth: 20, activeView: 'tasks' }) + fsCalls.dirPrefix = dir + fsCalls.recording = true + await vi.runAllTimersAsync() + fsCalls.recording = false + + expect(fsCalls.syncCalls).toEqual([]) + expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(10) + expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('terminal') + }) + + it('sweeps orphaned state and stats temp files before the final write', async () => { + const dir = makeDir() + const stateTemp = `${dataFile(dir)}.999999.1.orphan.tmp` + const statsTemp = `${statsFile(dir)}.999999.1.orphan.tmp` + const freshOtherProcessTemp = `${dataFile(dir)}.999998.1.live.tmp` + writeFileSync(stateTemp, 'stale', 'utf-8') + writeFileSync(statsTemp, 'stale', 'utf-8') + writeFileSync(freshOtherProcessTemp, 'in-flight', 'utf-8') + const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000 + utimesSync(stateTemp, staleSeconds, staleSeconds) + utimesSync(statsTemp, staleSeconds, staleSeconds) + + const store = await createStore(dir) + const stats = await createStatsCollector(dir) + store.updateUI({ sidebarWidth: 123 }) + stats.onAgentStart('pty-sweep', Date.now()) + await Promise.all([store.flushAsync(), stats.flushAsync()]) + + expect(existsSync(stateTemp)).toBe(false) + expect(existsSync(statsTemp)).toBe(false) + expect(existsSync(freshOtherProcessTemp)).toBe(true) + }) + + it('store.flushAsync() resolves instead of throwing when the profile mount rejects writes', async () => { + const dir = makeDir() + const store = await createStore(dir) + store.updateUI({ sidebarWidth: 42 }) + rmSync(dir, { recursive: true, force: true }) + + // It joins the teardown barrier; a rejection there is noise that must not cancel the quit. + await expect(store.flushAsync()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/quit-teardown-start-gate.test.ts b/src/main/quit-teardown-start-gate.test.ts new file mode 100644 index 00000000000..7c42a3979f6 --- /dev/null +++ b/src/main/quit-teardown-start-gate.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest' +import { QuitTeardownStartGate } from './quit-teardown-start-gate' + +describe('QuitTeardownStartGate', () => { + it('starts teardown once while vetoing every overlapping quit', () => { + const gate = new QuitTeardownStartGate() + const first = { preventDefault: vi.fn() } + const second = { preventDefault: vi.fn() } + + expect(gate.tryStart(first)).toBe(true) + expect(gate.tryStart(second)).toBe(false) + expect(first.preventDefault).toHaveBeenCalledTimes(1) + expect(second.preventDefault).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/quit-teardown-start-gate.ts b/src/main/quit-teardown-start-gate.ts new file mode 100644 index 00000000000..be970495a4b --- /dev/null +++ b/src/main/quit-teardown-start-gate.ts @@ -0,0 +1,12 @@ +export class QuitTeardownStartGate { + private started = false + + tryStart(event: { preventDefault(): void }): boolean { + event.preventDefault() + if (this.started) { + return false + } + this.started = true + return true + } +} diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 685aa1d506f..9173d192038 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -168,7 +168,7 @@ function buildWslCodexCommand( const execSuffix = `${args.map(shellQuote).join(' ')}${ options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' }` - // Why: npm/nvm launchers use `#!/usr/bin/env node`; exec'ing them from plain sh loses Node's PATH and pins stale installs. + // Why: npm/nvm launchers need Node's PATH; plain sh can select stale installs. const loginShellCommand = buildWslLoginShellCommand( [setupCommands, `exec codex ${execSuffix}`].join(' && ') ) @@ -313,7 +313,7 @@ function getBackendAuthRead( if (existing) { return existing } - // Why: Node can't cancel an in-flight UNC read; keep one read per auth path so repeated refreshes don't stack them. + // Why: dedupe UNC reads because Node cannot cancel an in-flight read. const read = createAuthFilesystemOperation(authPath, () => readFile(authPath, 'utf8').then( (content) => ({ content }), @@ -528,7 +528,7 @@ async function fetchViaBackend( if (!auth || signal.aborted) { return null } - // Why: reuse Codex's own get_rate_limit_status endpoint, avoiding a hidden app-server (and WSL login shell) per refresh. + // Why: reuse Codex's endpoint to avoid an app server and WSL login shell per refresh. const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { ...auth, signal @@ -538,7 +538,7 @@ async function fetchViaBackend( return null } const payload = (await response.json()) as BackendUsageResponse - // Why: plan_type is required by Codex's RateLimitStatusPayload; reject malformed JSON so the app-server fallback still runs. + // Why: reject missing plan_type so the app-server fallback runs. if (typeof payload.plan_type !== 'string') { return null } @@ -619,10 +619,10 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise ({ ORCA_WORKTREE_PATH: worktreePath }), loadHooks: vi.fn().mockReturnValue(null), + resolveSetupRunnerShell: vi.fn().mockReturnValue(undefined), runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), shouldRunSetupForCreate: vi .fn() @@ -679,6 +685,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(getEffectiveHooksFromConfig).mockReset() vi.mocked(getDefaultTabsLaunch).mockReset() vi.mocked(loadHooks).mockReset() + vi.mocked(resolveSetupRunnerShell).mockReset() vi.mocked(hasHooksFile).mockReset() vi.mocked(parseOrcaYaml).mockReset() vi.mocked(runHook).mockReset() @@ -688,6 +695,7 @@ function resetRuntimeTestMocks(): void { vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null) vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined) vi.mocked(loadHooks).mockReturnValue(null) + vi.mocked(resolveSetupRunnerShell).mockReturnValue(undefined) vi.mocked(hasHooksFile).mockReturnValue(false) vi.mocked(parseOrcaYaml).mockReturnValue(null) computeWorktreePathMock.mockReset() @@ -8470,6 +8478,55 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) }) + it('bounds decorative title delivery per paired client without reducing local frames', () => { + const { runtime, batches } = createSideEffectRuntime() + const firstClientEvents: RuntimeClientEvent[] = [] + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => firstClientEvents.push(event)) + + const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + firstClientEvents.length = 0 + + for (const frame of frames.slice(1)) { + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + } + } + + expect(firstClientEvents).toEqual([]) + expect(batches).toHaveLength(ptyIds.length * frames.length) + + const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` + runtime.onPtyData(ptyIds[0], bellChunk, 1) + expect(firstClientEvents).toEqual([ + expect.objectContaining({ + type: 'terminalSideEffects', + batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) + }) + ]) + firstClientEvents.length = 0 + + const secondClientEvents: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => secondClientEvents.push(event)) + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + + expect(firstClientEvents).toEqual([]) + expect(secondClientEvents).toHaveLength(ptyIds.length) + + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') + } + expect(firstClientEvents).toHaveLength(ptyIds.length) + expect(secondClientEvents).toHaveLength(ptyIds.length * 2) + }) + it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { const runtime = new OrcaRuntimeService(store) const desktopEvents: RuntimeClientEvent[] = [] @@ -33597,6 +33654,68 @@ describe('OrcaRuntimeService', () => { expect(killed).toBe(false) }) + it('stops by exact id when the selector no longer resolves', async () => { + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + // An orphaned workspace's repo is gone, so only the caller's id can identify it. + await expect( + runtime.stopTerminalsForWorktree('id:repo-gone::/tmp/gone', { + resolvedWorktreeId: TEST_WORKTREE_ID + }) + ).resolves.toEqual({ stopped: 1 }) + expect(kill).toHaveBeenCalledWith('pty-1') + }) + + it('does not sweep a sibling workspace sharing the checkout dir of an exact id', async () => { + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + // Regression for #10252: folder-workspace instances share one checkout dir, so comparing + // filesystem paths (which strip `::workspace:`) would match the root and its siblings + // and kill their live terminals. + await expect( + runtime.stopTerminalsForWorktree('id:repo-gone::/tmp/gone', { + resolvedWorktreeId: `${TEST_WORKTREE_ID}::workspace:11111111-1111-1111-1111-111111111111` + }) + ).resolves.toEqual({ stopped: 0 }) + expect(kill).not.toHaveBeenCalled() + }) + + it('does not sweep a same-id terminal owned by another connection', async () => { + const runtime = new OrcaRuntimeService(store) + const kill = vi.fn(() => true) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + await expect( + runtime.stopTerminalsForWorktree('id:repo-gone::/tmp/gone', { + resolvedWorktreeId: TEST_WORKTREE_ID, + resolvedConnectionId: 'ssh-1' + }) + ).resolves.toEqual({ stopped: 0 }) + expect(kill).not.toHaveBeenCalled() + }) + it('awaits physical PTY stop when destructive teardown supplies shared dedupe', async () => { const runtime = new OrcaRuntimeService(store) const physicalStop = makeDeferred() @@ -37626,6 +37745,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-test', 'pnpm worktree:setup', + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -37717,6 +37837,85 @@ describe('OrcaRuntimeService', () => { ) }) + it('passes the selected Windows setup shell into runtime runner generation', async () => { + setPlatform('win32') + const runtimeStore = { + ...store, + getSettings: () => ({ + ...store.getSettings(), + terminalWindowsShell: 'git-bash' + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + const activateWorktree = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-activate' + } + }) + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { + path: 'C:/workspaces/runtime-hook-activate', + head: 'def', + branch: 'runtime-hook-activate', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-activate', + runHooks: true, + activate: true + }) + + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), + 'C:\\workspaces\\runtime-hook-activate', + 'pnpm worktree:setup', + undefined, + { family: 'posix' } + ) + expect(result.setup).toMatchObject({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' } + }) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) + }) + it('follows normal setup policy for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -37781,6 +37980,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }), '/tmp/workspaces/runtime-hook-skip', 'pnpm worktree:setup', + undefined, undefined ) expect(runHook).not.toHaveBeenCalled() @@ -37834,6 +38034,224 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses returned WSL setup shell metadata when runtime spawns setup', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:/workspaces/runtime-hook-wsl', + head: 'def', + branch: 'runtime-hook-wsl', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-wsl' + }) + + expect(result.setup).toBeUndefined() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh', + env: expect.objectContaining({ + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl', + ORCA_TAB_ID: expect.stringMatching(UUID_RE), + ORCA_PANE_KEY: expect.any(String), + ORCA_WORKTREE_ID: result.worktree.id + }), + worktreeId: result.worktree.id + }) + ) + }) + + it('uses the shell-aware setup runner for windowless creates without a startup command', async () => { + // Regression (C1): with no authoritative window and no startup command the + // create fell back to runHook, which hardcodes cmd.exe on Windows and so ran + // batch even when the configured terminal resolves to Git Bash. + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-windowless' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-primary' }) + .mockResolvedValueOnce({ id: 'pty-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // Deliberately no attachWindow: this is the windowless/CLI create path. + + computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless') + ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' }) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix' }, + envVars: { + ORCA_ROOT_PATH: 'C:\\repo', + ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-windowless' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: 'C:/workspaces/runtime-hook-windowless', + head: 'def', + branch: 'runtime-hook-windowless', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-windowless', + awaitTerminalProvisioning: true + }) + + expect(createSetupRunnerScript).toHaveBeenCalledWith( + expect.objectContaining({ id: 'repo-1' }), + 'C:\\workspaces\\runtime-hook-windowless', + 'pnpm worktree:setup', + undefined, + { family: 'posix' } + ) + expect(runHook).not.toHaveBeenCalled() + expect(result.setupReceipt).toMatchObject({ state: 'running' }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(spawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'bash /c/repo/.git/orca/setup-runner.sh', + worktreeId: result.worktree.id + }) + ) + }) + + it('reports the in-process setup hook as running when nothing can launch the runner', async () => { + // Regression (C1): the fire-and-forget hook is the last resort when there is + // no PTY controller; reporting spawn_failed made callers retry a live hook. + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(runHook).mockResolvedValue({ success: true, output: '' }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-hook-no-pty', + head: 'def', + branch: 'runtime-hook-no-pty', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-hook-no-pty', + awaitTerminalProvisioning: true + }) + + expect(createSetupRunnerScript).not.toHaveBeenCalled() + expect(runHook).toHaveBeenCalledWith( + 'setup', + '/tmp/workspaces/runtime-hook-no-pty', + expect.objectContaining({ id: 'repo-1' }), + '/tmp/workspaces/runtime-hook-no-pty', + undefined + ) + expect(result.setupReceipt).toMatchObject({ state: 'running' }) + }) + it('sequences setup before startup for opted-in local headless worktree creates', async () => { const waitRepo = { ...store.getRepo('repo-1')!, @@ -37886,7 +38304,8 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) vi.mocked(createSetupRunnerScript).mockReturnValue({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup' @@ -37923,6 +38342,8 @@ describe('OrcaRuntimeService', () => { const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/) expect(nonceMatch?.[1]).toBeTruthy() expect(startupCommand).toContain('exec claude') + expect(startupCommand).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') expect(setupCommand).toContain('printf') expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`) expect(result.setup).toBeUndefined() @@ -38005,6 +38426,75 @@ describe('OrcaRuntimeService', () => { }) }) + it('observes setup completion through the launch shell the runner was written for', async () => { + const runtime = new OrcaRuntimeService(store) + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-observed-wsl-shell' }) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-observed-wsl-startup' }) + .mockResolvedValueOnce({ id: 'pty-observed-wsl-setup' }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell') + vi.mocked(getEffectiveHooks).mockReturnValue({ + scripts: { + setup: 'pnpm worktree:setup' + } + }) + vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) + vi.mocked(createSetupRunnerScript).mockReturnValue({ + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, + envVars: { + ORCA_ROOT_PATH: '/tmp/repo', + ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-observed-wsl-shell' + } + }) + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: '/tmp/workspaces/runtime-observed-wsl-shell', + head: 'def', + branch: 'runtime-observed-wsl-shell', + isBare: false, + isMainWorktree: false + } + ]) + + await runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'runtime-observed-wsl-shell', + setupDecision: 'run', + startup: { command: 'claude' }, + observeSetupCompletion: true, + awaitTerminalProvisioning: true + }) + + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command + expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') + expect(setupCommand).toContain('__ORCA_SETUP_COMPLETE__:') + }) + it('creates the first terminal for CLI-created worktrees without activating them', async () => { const runtime = new OrcaRuntimeService(store) const activateWorktree = vi.fn() @@ -39219,7 +39709,8 @@ describe('OrcaRuntimeService', () => { }) vi.mocked(shouldRunSetupForCreate).mockReturnValue(true) vi.mocked(createSetupRunnerScript).mockReturnValue({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + shell: { family: 'posix', executable: 'wsl.exe' }, envVars: { ORCA_ROOT_PATH: '/tmp/repo', ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry' @@ -39249,8 +39740,8 @@ describe('OrcaRuntimeService', () => { 'repo-1', expect.any(String), expect.objectContaining({ - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') + runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh', + command: expect.stringContaining('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh') }), undefined, undefined @@ -41617,6 +42108,194 @@ describe('OrcaRuntimeService', () => { expect(removeWorktreeLinkedPathsMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH, ['node_modules']) }) + it('forgets exact-id orphan metadata when the parent repo is already gone', async () => { + const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'runtime:env-1' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const runtime = createWorktreeRemovalRuntime(orphanStore) + + // Nothing left the disk, so non-desktop callers must be able to tell "forgotten" from "deleted". + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({ + warning: expect.stringContaining(TEST_WORKTREE_PATH) + }) + + expect(removeWorktreeMeta).toHaveBeenCalledWith(TEST_WORKTREE_ID, 'runtime:env-1') + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + // Nothing is deleted on disk here, so the surviving directory's watchers must still be released. + expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith( + TEST_WORKTREE_PATH, + expect.objectContaining({ remainingMs: expect.any(Function) }) + ) + // Regression: the directory survives, so its watchers must be restored rather than forgotten. + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(forgetLocalWatcherRemovalSnapshotMock).not.toHaveBeenCalled() + }) + + it('scopes a runtime-host orphan PTY sweep to its environment, not the local host', async () => { + const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'runtime:env-1' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const localProvider = { + listProcesses: vi.fn(async () => [{ id: `${TEST_WORKTREE_ID}@@1` }]), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(orphanStore as never, undefined, { + getLocalProvider: () => localProvider as never + }) + const stopAndWait = vi.fn().mockResolvedValue(true) + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'local-pty-1') + runtime.registerPty('local-pty-1', TEST_WORKTREE_ID) + registerLocalPtyMemoryRow({ + ptyId: 'local-pty-1', + worktreeId: TEST_WORKTREE_ID, + sessionId: null, + paneKey: null, + pid: null + }) + + try { + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + } finally { + unregisterLocalPtyMemoryRow('local-pty-1') + } + + // Regression: `repoId::path` collides across hosts, so a runtime-owned orphan + // must never kill the live PTYs of a same-id LOCAL workspace. + expect(localProvider.listProcesses).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(stopAndWait).not.toHaveBeenCalled() + }) + + it('still sweeps the local host for an ownerless orphan', async () => { + const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const localProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(orphanStore as never, undefined, { + getLocalProvider: () => localProvider as never + }) + const stopAndWait = vi.fn().mockResolvedValue(true) + runtime.setPtyController({ + write: () => true, + kill: () => true, + stopAndWait, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'local-pty-1') + runtime.registerPty('local-pty-1', TEST_WORKTREE_ID) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(localProvider.listProcesses).toHaveBeenCalled() + expect(stopAndWait).toHaveBeenCalledWith('local-pty-1', expect.anything()) + }) + + it('restores rather than forgets watchers for the orphan directory that survives', async () => { + const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'runtime:env-1' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const runtime = createWorktreeRemovalRuntime(orphanStore) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + // Regression: the gate must finish(false) — forgetting watchers would silently deafen a + // folder workspace or File Explorer pane rooted at this still-present directory. + expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith( + TEST_WORKTREE_PATH, + expect.objectContaining({ remainingMs: expect.any(Function) }) + ) + expect(restoreLocalWatcherAfterFailedRemovalMock).toHaveBeenCalledWith(TEST_WORKTREE_PATH) + expect(forgetLocalWatcherRemovalSnapshotMock).not.toHaveBeenCalled() + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('warns that a missing-repo removal only forgot the workspace', async () => { + const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'runtime:env-1' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const runtime = createWorktreeRemovalRuntime(orphanStore) + + const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + // Regression: CLI and mobile share this method, so success alone must not read as "deleted". + expect(result.warning).toEqual(expect.stringContaining(TEST_WORKTREE_PATH)) + expect(result.warning).toEqual(expect.stringContaining(TEST_REPO_ID)) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('sweeps an orphaned SSH worktree through its host provider', async () => { + const { runtimeStore } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID, { + hostId: 'ssh:ssh-1' + }) + const orphanStore = { + ...runtimeStore, + getRepos: () => [], + getRepo: () => undefined + } + const localProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + const sshProvider = { + listProcesses: vi.fn(async () => []), + shutdown: vi.fn(async () => {}) + } + const getSshProvider = vi.fn(() => sshProvider as never) + const runtime = new OrcaRuntimeService(orphanStore as never, undefined, { + getLocalProvider: () => localProvider as never, + getSshProvider + }) + + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).resolves.toEqual({ + warning: expect.stringContaining(TEST_WORKTREE_PATH) + }) + + expect(getSshProvider).toHaveBeenCalledWith('ssh-1') + expect(sshProvider.listProcesses).toHaveBeenCalled() + expect(localProvider.listProcesses).not.toHaveBeenCalled() + expect(closeRemoteWatcherForWorktreePathMock).toHaveBeenCalledWith('ssh-1', TEST_WORKTREE_PATH) + // The remote directory survives, so its watchers are restored, not forgotten. + expect(restoreRemoteWatcherAfterFailedRemovalMock).toHaveBeenCalledWith( + 'ssh-1', + TEST_WORKTREE_PATH + ) + expect(forgetRemoteWatcherRemovalSnapshotMock).not.toHaveBeenCalled() + }) + it('does not remove a runtime worktree when watcher teardown cannot release it', async () => { const repo = { ...store.getRepos()[0], symlinkPaths: ['node_modules'] } const runtimeStore = { ...store, getRepos: () => [repo], getRepo: () => repo } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 963c91811b9..6e255216ecc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -512,7 +512,7 @@ import { advanceTerminalTopologyRevision, hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority' -import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' +import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -840,6 +840,7 @@ import { loadHooks, parseOrcaYaml, readIssueCommand, + resolveSetupRunnerShell, runHook, shouldRunSetupForCreate, writeIssueCommand @@ -2637,6 +2638,13 @@ async function hasLocalWorktreeBaseRef( ) } +function getSetupRunnerCommandPlatformForLaunch( + setup: CreateWorktreeResult['setup'], + fallbackPlatform: 'windows' | 'posix' +): 'windows' | 'posix' { + return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -2744,6 +2752,10 @@ export class OrcaRuntimeService { private terminalSideEffectExcludedClientEventListeners = new Set< (event: RuntimeClientEvent) => void >() + private terminalSideEffectTitleGateKeysByClientEventListener = new Map< + (event: RuntimeClientEvent) => void, + Map + >() private nativeChatLaunchDraftResolutionByTabId = new Map< string, NativeChatLaunchDraftResolutionTombstone @@ -4764,11 +4776,14 @@ export class OrcaRuntimeService { this.clientEventListeners.add(listener) if (options?.consumesTerminalSideEffects === false) { this.terminalSideEffectExcludedClientEventListeners.add(listener) + } else { + this.terminalSideEffectTitleGateKeysByClientEventListener.set(listener, new Map()) } this.refreshTerminalSideEffectConsumerAvailability() return () => { this.clientEventListeners.delete(listener) this.terminalSideEffectExcludedClientEventListeners.delete(listener) + this.terminalSideEffectTitleGateKeysByClientEventListener.delete(listener) this.refreshTerminalSideEffectConsumerAvailability() } } @@ -4834,27 +4849,53 @@ export class OrcaRuntimeService { } private emitClientEvent(event: RuntimeClientEvent): void { - // Why: mobile streams discard terminalSideEffects; skip excluded listeners so - // paired phones never receive the per-OSC batch frames over the relay. Filtered - // inside the delivery callback to keep live-Set iteration (a listener that - // unsubscribes mid-fan-out must not be delivered to) and stay allocation-free. - const skipExcluded = - event.type === 'terminalSideEffects' && - this.terminalSideEffectExcludedClientEventListeners.size > 0 + // Why: filter inside live-Set delivery so a listener removed mid-fan-out + // receives nothing and each paired client gets one semantic title frame. // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. notifyRuntimeListeners( this.clientEventListeners, (listener) => { - if (skipExcluded && this.terminalSideEffectExcludedClientEventListeners.has(listener)) { - return + if (event.type === 'terminalSideEffects') { + const filtered = this.filterTerminalSideEffectEventForClient(listener, event) + if (filtered) { + listener(filtered) + } + } else { + listener(event) } - listener(event) }, 'client-event' ) } + private filterTerminalSideEffectEventForClient( + listener: (event: RuntimeClientEvent) => void, + event: Extract + ): Extract | null { + const titleGateKeys = this.terminalSideEffectTitleGateKeysByClientEventListener.get(listener) + if (!titleGateKeys) { + return null + } + const facts = event.batch.facts.filter((fact) => { + if (fact.kind !== 'title') { + return true + } + const gateKey = this.makeDecorativeTitleGateKey(fact.rawTitle, fact.normalizedTitle) + if (titleGateKeys.get(event.batch.ptyId) === gateKey) { + return false + } + titleGateKeys.set(event.batch.ptyId, gateKey) + return true + }) + if (facts.length === 0) { + return null + } + return facts.length === event.batch.facts.length + ? event + : { ...event, batch: { ...event.batch, facts } } + } + notifyNativeChatLaunchDraftResolved( handle: string, resolution: { text: string; createdAt: number } @@ -5167,7 +5208,6 @@ export class OrcaRuntimeService { setEmulatorBridge(bridge: EmulatorBridge | null): void { this.emulatorBridge = bridge - setEmulatorBridge(bridge) } getEmulatorBridge(): EmulatorBridge | null { @@ -9705,7 +9745,7 @@ export class OrcaRuntimeService { /** Decorative comparison key: spinner frame glyphs stripped, derived agent * status kept so a working→idle flip with an otherwise-equal label still * counts as a change. */ - private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string { + private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( normalizedTitle )}` @@ -9743,7 +9783,7 @@ export class OrcaRuntimeService { return } const live = this.ptyTitleTrackersByPtyId.get(ptyId) - const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle) + const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey @@ -9898,6 +9938,9 @@ export class OrcaRuntimeService { private disposePtyTitleTracker(ptyId: string): void { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) + for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { + titleGateKeys.delete(ptyId) + } } private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { @@ -18623,6 +18666,7 @@ export class OrcaRuntimeService { repoSelector: string branch: string currentHeadOid?: string | null + active?: boolean linkedGitHubPR?: number | null fallbackGitHubPR?: number | null linkedGitLabMR?: number | null @@ -18637,6 +18681,7 @@ export class OrcaRuntimeService { connectionId: repo.connectionId ?? null, branch: args.branch, currentHeadOid: args.currentHeadOid ?? null, + ...(args.active === true ? { active: true } : {}), linkedGitHubPR: args.linkedGitHubPR ?? null, fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, linkedGitLabMR: args.linkedGitLabMR ?? null, @@ -20529,13 +20574,18 @@ export class OrcaRuntimeService { ? buildObservedSetupCommand( args.setup.runnerScriptPath, args.setupCommandPlatform, - completionToken + completionToken, + args.setup.shell ) : null const setupCommand = args.wrappedSetupCommand ?? observedCommand?.command ?? - buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) + buildSetupRunnerCommand( + args.setup.runnerScriptPath, + args.setupCommandPlatform, + args.setup.shell + ) const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } const shouldSplitSetup = primaryTerminalHandle && @@ -21470,17 +21520,27 @@ export class OrcaRuntimeService { : undefined } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + // Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run + // when nothing downstream is able to launch the shell-aware runner script. + let didStartInProcessSetupHook = false if (shouldRunSetup && hooks?.scripts.setup) { - const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) + const shouldUseSetupRunner = + this.authoritativeWindowId !== null || + Boolean(effectiveStartup) || + Boolean(this.ptyController?.spawn) if (shouldUseSetupRunner) { try { // Why: setup+startup must share the terminal runner path even without - // a renderer window, so the startup shell can wait on setup completion. + // a renderer window, so the startup shell can wait on setup completion + // and windowless creates resolve the same Windows setup shell. + const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] + // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, hooks.scripts.setup, - this.getLocalGitExecutionOptionArgs(repo)[0] + runtimeTarget, + resolveSetupRunnerShell(settings) ) } catch (error) { // Why: the git worktree is already real at this point. If runner @@ -21489,6 +21549,7 @@ export class OrcaRuntimeService { console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } } else { + didStartInProcessSetupHook = true void runHook( 'setup', worktreePath, @@ -21534,14 +21595,15 @@ export class OrcaRuntimeService { let sequencedStartup = effectiveStartup let wrappedSetupCommandStr: string | undefined if (effectiveStartup && setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, + const platform = getSetupRunnerCommandPlatformForLaunch( + setup, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: effectiveStartup.command, - platform + platform, + shell: setup.shell }) sequencedStartup = { ...effectiveStartup, @@ -21613,11 +21675,7 @@ export class OrcaRuntimeService { ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: setup - ? isWindowsAbsolutePathLike(setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // Setup tab runs the same script the sequenced agent waits on. @@ -21668,11 +21726,7 @@ export class OrcaRuntimeService { ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: setup - ? isWindowsAbsolutePathLike(setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false @@ -21739,7 +21793,9 @@ export class OrcaRuntimeService { ? ('not_configured' as const) : effectiveDecision === 'skip' || !shouldRunSetup ? ('skipped' as const) - : didSpawnSetup + : // Why: the in-process hook is already executing, so reporting + // spawn_failed would strand callers that retry on it. + didSpawnSetup || didStartInProcessSetupHook ? ('running' as const) : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) @@ -21890,11 +21946,12 @@ export class OrcaRuntimeService { let sequencedStartup = args.startup let wrappedSetupCommandStr: string | undefined if (args.startup && result.setup?.waitForAgentStartup === true) { - const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') + const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: result.setup.runnerScriptPath, startupCommand: args.startup.command, - platform + platform, + shell: result.setup.shell }) sequencedStartup = { ...args.startup, @@ -21961,11 +22018,7 @@ export class OrcaRuntimeService { ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: result.setup - ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // remote Setup tab runs the same script the sequenced agent waits on. @@ -22021,11 +22074,7 @@ export class OrcaRuntimeService { ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, - setupCommandPlatform: result.setup - ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) - ? 'windows' - : 'posix' - : 'posix', + setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false @@ -23074,7 +23123,13 @@ export class OrcaRuntimeService { private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void { // Why: worktree IDs are path-derived and can be recreated, so removal must // purge history and process-local caches before the ID points at new state. - store.removeWorktreeMeta(worktreeId) + const hostId = store.getWorktreeMeta(worktreeId)?.hostId + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } + this.mobileSessionTabsByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) @@ -23227,7 +23282,61 @@ export class OrcaRuntimeService { return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { const repo = store.getRepo(removalTarget.repoId) if (!repo) { - throw new Error('repo_not_found') + const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) + const sshPtyProvider = + orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined + const ptyProvider = sshPtyProvider ?? this.getLocalProvider() + const externalOrphanHost = orphanHost?.kind === 'ssh' || orphanHost?.kind === 'runtime' + if (ptyProvider) { + // External host inventories must never sweep a same-id local workspace. + await killAllProcessesForWorktree(removalTarget.id, { + runtime: this, + resolvedWorktreeId: removalTarget.id, + ...(orphanHost?.kind === 'ssh' ? { resolvedConnectionId: orphanHost.targetId } : {}), + ...(orphanHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: orphanHost.environmentId } + : {}), + localProvider: ptyProvider, + onPtyStopped: this.onPtyStopped ?? undefined, + ...(externalOrphanHost + ? { + includeProviderInventory: orphanHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((error) => { + console.warn( + `[worktree-teardown] orphan cleanup failed for ${removalTarget.id}:`, + error + ) + }) + } + // Why: nothing is deleted on disk here, so watchers must be restored — a folder + // workspace or explorer pane rooted at the same path stays live. + const orphanFullPath = splitWorktreeId(removalTarget.id)?.worktreePath + const orphanWatcherPath = + splitWorktreeIdForFilesystem(removalTarget.id)?.worktreePath === orphanFullPath + ? orphanFullPath + : undefined + if (orphanWatcherPath) { + await this.acquireFileWatcherRemoval( + orphanWatcherPath, + orphanHost?.kind === 'ssh' ? orphanHost.targetId : undefined + ) + .then((gate) => gate.finish(false)) + .catch(() => {}) + } + this.clearOptimisticReconcileToken(removalTarget.id) + this.removeWorktreeMetadataAndHistory(store, removalTarget.id) + this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) + invalidateAuthorizedRootsCache() + this.notifyWorktreesChanged(removalTarget.repoId) + // Why: non-desktop callers must be able to tell "forgotten" from "deleted"; nothing left the disk. + return { + warning: `Project ${removalTarget.repoId} is no longer tracked, so ${removalTarget.path} was forgotten without deleting the directory or its Git worktree registration.` + } } if (isFolderRepo(repo)) { if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { @@ -26017,23 +26126,57 @@ export class OrcaRuntimeService { ptyId: string, stop: () => boolean | Promise ) => Promise<{ stopped: boolean; owner: boolean }> + /** Authoritative id for an orphan whose selector no longer resolves. */ + resolvedWorktreeId?: string + resolvedConnectionId?: string + resolvedRuntimeEnvironmentId?: string } = {} ): Promise<{ stopped: number }> { // Why: this mutates live PTYs, so reject while the graph is reloading rather than act on cached leaf ownership. const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = options.resolvedWorktreeId + ? { id: options.resolvedWorktreeId } + : await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) if (options.deadline !== undefined && Date.now() >= options.deadline) { return { stopped: 0 } } + // Preserve folder-instance suffixes while normalizing cross-platform path spelling. + const parsedTarget = splitWorktreeId(worktree.id) + const ownsWorktree = options.resolvedWorktreeId + ? (candidate: string | undefined): boolean => { + if (!candidate) { + return false + } + const parsedCandidate = splitWorktreeId(candidate) + return parsedCandidate && parsedTarget + ? parsedCandidate.repoId === parsedTarget.repoId && + runtimePathsEqual(parsedCandidate.worktreePath, parsedTarget.worktreePath) + : candidate === worktree.id + } + : (candidate: string | undefined): boolean => candidate === worktree.id + const ownsHost = (ptyId: string, connectionId?: string | null): boolean => { + if (options.resolvedRuntimeEnvironmentId !== undefined) { + return ptyId.startsWith( + `remote:${encodeURIComponent(options.resolvedRuntimeEnvironmentId)}@@` + ) + } + return ( + options.resolvedConnectionId === undefined || connectionId === options.resolvedConnectionId + ) + } const ptyIds = new Set() for (const leaf of this.leaves.values()) { - if (leaf.worktreeId === worktree.id && leaf.ptyId) { + if ( + ownsWorktree(leaf.worktreeId) && + leaf.ptyId && + ownsHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId) + ) { ptyIds.add(leaf.ptyId) } } for (const pty of this.ptysById.values()) { - if (pty.worktreeId === worktree.id && pty.connected) { + if (ownsWorktree(pty.worktreeId) && pty.connected && ownsHost(pty.ptyId, pty.connectionId)) { ptyIds.add(pty.ptyId) } } diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 5d24f68a107..6f31f5df261 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -20,8 +20,6 @@ const AGENT_NAME_GROUPS = [ type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number] -export type GroupAddress = '@all' | '@idle' | `@${AgentNameGroup}` | `@worktree:${string}` - export function isGroupAddress(to: string): boolean { return to.startsWith('@') } diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts index 0f2b3976f1e..baa5af51cf3 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -41,6 +41,29 @@ describe('orchestration setup completion signal', () => { expect(command).toContain('exit "$status"') }) + it('routes a WSL-launched Windows-drive runner through its /mnt mount', () => { + const { command } = buildObservedSetupCommand( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + 'windows', + 'token-mnt', + { family: 'posix', executable: 'wsl.exe' } + ) + + expect(command).toContain('bash /mnt/c/repo/.git/orca/setup-runner.sh') + expect(command).not.toContain('bash /c/repo') + }) + + it('keeps a Git Bash runner on the MSYS drive form', () => { + const { command } = buildObservedSetupCommand( + 'C:\\repo\\.git\\orca\\setup-runner.sh', + 'windows', + 'token-git-bash', + { family: 'posix' } + ) + + expect(command).toContain('bash /c/repo/.git/orca/setup-runner.sh') + }) + it('recognizes one completion signal across output chunk boundaries', () => { const onComplete = vi.fn() const scanner = createSetupCompletionScanner('token-chunks', onComplete) diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 17819c12a0c..23e22008387 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -1,6 +1,7 @@ import { resolveSetupRunnerCommand, - type SetupRunnerCommandPlatform + type SetupRunnerCommandPlatform, + type SetupRunnerShell } from '../../../shared/setup-runner-command' const SETUP_COMPLETION_PREFIX = '__ORCA_SETUP_COMPLETE__:' @@ -10,9 +11,12 @@ const WINDOWS_SETUP_RUNNER_ENV = 'ORCA_SETUP_RUNNER_PATH' export function buildObservedSetupCommand( runnerScriptPath: string, platform: SetupRunnerCommandPlatform, - completionToken: string + completionToken: string, + // Why: the observed command must reuse the shell the runner was written for, or a + // WSL-routed Windows-drive runner gets Git Bash `/c/...` instead of `/mnt/c/...`. + shell?: SetupRunnerShell ): { command: string; env?: Record } { - const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform) + const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform, shell) if (resolution.shell === 'windows') { const script = [ `$runner = $env:${WINDOWS_SETUP_RUNNER_ENV}`, diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts index 9911d837e7e..005b33fb8df 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -274,4 +274,32 @@ describe('RelayAuthCoordinator', () => { await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[2])) expect(brokers[1]!.closeNow).toHaveBeenCalledOnce() }) + + it('reopens instead of republishing registered for a dead broker', async () => { + // Why: a broker that lost its control without recovering must not keep + // reporting registered on every reconcile while phones get HOST_OFFLINE. + let firstBrokerLive = true + const brokers = [ + { closeNow: vi.fn(), isLive: () => firstBrokerLive }, + { closeNow: vi.fn(), isLive: () => true } + ] + const openBroker = vi.fn().mockResolvedValueOnce(brokers[0]).mockResolvedValueOnce(brokers[1]) + const statuses: string[] = [] + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: (status) => statuses.push(status) + }) + coordinator.reconcile() + await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[0])) + expect(statuses.at(-1)).toBe('registered') + + firstBrokerLive = false + coordinator.reconcile() + + await vi.waitFor(() => expect(coordinator.getActiveBroker()).toBe(brokers[1])) + expect(brokers[0]!.closeNow).toHaveBeenCalledOnce() + expect(statuses.at(-1)).toBe('registered') + expect(statuses.filter((status) => status === 'connecting')).toHaveLength(2) + }) }) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index c2457e91595..a09c8f6374a 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -15,6 +15,7 @@ export type RelayAuthContext = { export type CoordinatedRelayBroker = { closeNow(): void + isLive?(): boolean } type RelayAuthCoordinatorOptions = { @@ -143,7 +144,13 @@ export class RelayAuthCoordinator { return } this.cancelLinger() - if (this.ownership?.valid && this.ownership.identityKey === nextIdentityKey) { + if ( + this.ownership?.valid && + this.ownership.identityKey === nextIdentityKey && + // Why: registered must be provable; a broker whose control died without + // recovering falls through and is replaced instead of republished. + (this.ownership.broker?.isLive?.() ?? true) + ) { this.retryAttempt = 0 this.options.onStatus('registered') return @@ -181,6 +188,12 @@ export class RelayAuthCoordinator { this.options.onStatus('registered') } catch (error) { if (this.isEpochCurrent(epoch)) { + // Why: silent broker-open failures made a dead relay look like standby + // during incident diagnosis; the message carries operation + status. + console.warn( + '[relay] broker reconcile failed:', + error instanceof Error ? error.message : String(error) + ) this.options.onStatus('offline') if (shouldRetryRelayConnectionError(error)) { const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index d06a7702169..a1ad5c03482 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -1,4 +1,5 @@ import { createHash, createHmac, randomBytes } from 'node:crypto' +import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket } from 'ws' @@ -378,3 +379,174 @@ describe('RelayControlClient', () => { await vi.waitFor(() => expect(onDrain).toHaveBeenCalledOnce()) }) }) + +class FakeControlSocket extends EventEmitter { + readonly OPEN = 1 + readyState = 1 + script: ((message: Record, socket: FakeControlSocket) => void) | null = null + + send(payload: string): void { + this.script?.(JSON.parse(payload) as Record, this) + } + + close(code = 1000): void { + if (this.readyState !== 1) { + return + } + this.readyState = 3 + this.emit('close', code) + } + + terminate(): void { + this.close(1006) + } + + deliver(message: object): void { + this.emit('message', JSON.stringify(message), false) + } +} + +function scriptedControl(options: { closeWithAck?: boolean; issuedAtOffsetMs?: number } = {}): { + client: RelayControlClient + socket: FakeControlSocket + onClose: ReturnType +} { + const hostKeys = nacl.box.keyPair() + const keypair: E2EEKeypair = { + publicKey: hostKeys.publicKey, + secretKey: hostKeys.secretKey, + publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64') + } + const origin = 'http://relay.test' + const relayHostId = createHash('sha256') + .update(hostKeys.publicKey) + .digest('base64url') + .slice(0, 16) + const socket = new FakeControlSocket() + socket.script = (message, ws) => { + if (message.type === 'host-hello') { + const relayKeys = nacl.box.keyPair() + const nonce = randomBytes(24) + const secret = randomBytes(32) + const issuedAt = Date.now() + (options.issuedAtOffsetMs ?? 0) + const expiresAt = issuedAt + 10_000 + const transcript = buildTranscript({ + origin, + relayKey: relayKeys.publicKey, + nonce, + challengeId: 'challenge-1', + issuedAt, + expiresAt, + relayHostId, + hostKey: hostKeys.publicKey + }) + const plaintext = concat([ + text(`${CHALLENGE_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + secret + ]) + ws.deliver({ + type: 'host-challenge', + challengeId: 'challenge-1', + relayEphemeralPublicKeyB64: Buffer.from(relayKeys.publicKey).toString('base64'), + nonceB64: nonce.toString('base64'), + ciphertextB64: Buffer.from( + nacl.box(plaintext, nonce, hostKeys.publicKey, relayKeys.secretKey) + ).toString('base64'), + expiresAt + }) + return + } + if (message.type === 'host-challenge-ack') { + ws.deliver({ + type: 'host-hello-ack', + v: 1, + generation: 4, + controlResumeSecret: randomBytes(32).toString('base64url'), + leaseExpiresAt: Date.now() + 3_600_000, + activeConnIds: [], + pendingConns: [] + }) + // Same ws parser turn: the close event fires before any awaiting caller + // of connect() gets to run. + if (options.closeWithAck) { + ws.close(1006) + } + } + } + const onClose = vi.fn() + const client = new RelayControlClient({ + cellUrl: origin, + relayJwt: 'scoped-token', + relayHostId, + assignmentEpoch: 3, + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair, + appVersion: '1.2.3', + onConnectionOpen: vi.fn(), + onDrain: vi.fn(), + onClose, + createSocket: () => socket as unknown as WebSocket + }) + queueMicrotask(() => socket.emit('open')) + return { client, socket, onClose } +} + +describe('RelayControlClient scripted-socket lifecycle', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('resolves connect when close lands in the ack parser turn, but reports not live', async () => { + const { client, onClose } = scriptedControl({ closeWithAck: true }) + + await expect(client.connect()).resolves.toMatchObject({ generation: 4 }) + + expect(onClose).toHaveBeenCalledWith(1006) + expect(client.isLive()).toBe(false) + }) + + it('tolerates a cell clock slightly ahead when validating the challenge', async () => { + // A relay whose clock runs ~100ms-2s ahead is normal NTP drift, not replay. + const { client } = scriptedControl({ issuedAtOffsetMs: 1_500 }) + + await expect(client.connect()).resolves.toMatchObject({ generation: 4 }) + expect(client.isLive()).toBe(true) + }) + + it('rejects a challenge issued beyond the clock tolerance', async () => { + const { client } = scriptedControl({ issuedAtOffsetMs: 45_000 }) + + await expect(client.connect()).rejects.toThrow('invalid host challenge') + expect(client.isLive()).toBe(false) + }) + + it('terminates a silent control after the silence limit and reports closure', async () => { + vi.useFakeTimers() + const { client, socket, onClose } = scriptedControl() + await expect(client.connect()).resolves.toMatchObject({ generation: 4 }) + expect(client.isLive()).toBe(true) + + vi.advanceTimersByTime(91_000) + + expect(socket.readyState).toBe(3) + expect(onClose).toHaveBeenCalledWith(1006) + expect(client.isLive()).toBe(false) + }) + + it('keeps a control live while server pings keep arriving', async () => { + vi.useFakeTimers() + const { client, socket } = scriptedControl() + await client.connect() + + for (let round = 0; round < 6; round++) { + vi.advanceTimersByTime(60_000) + socket.deliver({ type: 'ping', t: Date.now() }) + } + expect(client.isLive()).toBe(true) + + vi.advanceTimersByTime(91_000) + expect(client.isLive()).toBe(false) + }) +}) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index f4ba141ae0d..76816c81a3a 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -17,6 +17,11 @@ import { import { RelayControlRequests } from './relay-control-requests' import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { answerRelayHostChallenge } from './relay-host-proof' +import { + RELAY_CONTROL_SILENCE_LIMIT_MS, + RelayControlSilenceWatchdog +} from './relay-control-silence-watchdog' +import { controlWebSocketUrl } from './relay-control-url' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' @@ -35,26 +40,11 @@ type RelayControlClientOptions = { onClose: (code: number) => void createSocket?: (url: string, relayJwt: string) => WebSocket connectDeadlineMs?: number + silenceLimitMs?: number } const RELAY_CONTROL_CONNECT_DEADLINE_MS = 15_000 -function controlWebSocketUrl(cellUrl: string): { origin: string; url: string } { - const parsed = new URL(cellUrl) - if (parsed.pathname !== '/' || parsed.search || parsed.hash) { - throw new Error('relay_cell_url_must_be_an_origin') - } - const origin = parsed.origin - if (parsed.protocol === 'https:') { - parsed.protocol = 'wss:' - } else if (parsed.protocol === 'http:') { - parsed.protocol = 'ws:' - } else { - throw new Error('relay_cell_url_must_use_http') - } - return { origin, url: `${parsed.origin}/v1/host/control` } -} - export class RelayControlClient { private readonly options: RelayControlClientOptions private readonly relayOrigin: string @@ -66,12 +56,17 @@ export class RelayControlClient { private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null private connectReject: ((error: Error) => void) | null = null private connectTimer: ReturnType | null = null + private readonly silenceWatchdog: RelayControlSilenceWatchdog constructor(options: RelayControlClientOptions) { this.options = options const endpoint = controlWebSocketUrl(options.cellUrl) this.relayOrigin = endpoint.origin this.controlUrl = endpoint.url + this.silenceWatchdog = new RelayControlSilenceWatchdog( + options.silenceLimitMs ?? RELAY_CONTROL_SILENCE_LIMIT_MS, + () => this.socket?.terminate() + ) this.createSocket = options.createSocket ?? ((url, token) => @@ -91,6 +86,7 @@ export class RelayControlClient { this.socket = socket socket.once('open', () => this.sendHostHello()) socket.on('message', (raw, isBinary) => { + this.silenceWatchdog.noteInbound() if (isBinary) { this.failProtocol('binary control message') return @@ -120,6 +116,14 @@ export class RelayControlClient { return this.requests.size } + isLive(): boolean { + return ( + (this.state === 'active' || this.state === 'draining') && + this.socket !== null && + this.socket.readyState === WebSocket.OPEN + ) + } + refreshAuthorization(relayJwt: string): void { this.sendActive({ type: 'auth-refresh', relayJwt }) } @@ -165,6 +169,7 @@ export class RelayControlClient { closeNow(): void { const wasConnecting = this.state === 'opening' || this.state === 'proving' this.state = 'closed' + this.silenceWatchdog.stop() if (wasConnecting) { this.connectReject?.(new Error('relay_control_closed')) this.clearConnectPromise() @@ -235,6 +240,7 @@ export class RelayControlClient { private handleProofMessage(message: Record): void { const challenge = RelayHostChallengeMessageSchema.safeParse(message) if (challenge.success) { + let invalidReason = 'unknown' const proofB64 = answerRelayHostChallenge(challenge.data, { relayOrigin: this.relayOrigin, ...this.options.identity, @@ -243,10 +249,14 @@ export class RelayControlClient { hostSecretKey: this.options.keypair.secretKey, assignmentEpoch: this.options.assignmentEpoch, previousGeneration: this.options.previousGeneration, - resumeRequested: Boolean(this.options.controlResumeSecret) + resumeRequested: Boolean(this.options.controlResumeSecret), + onInvalid: (reason) => { + invalidReason = reason + } }) if (!proofB64) { - this.failProtocol('invalid host challenge') + // Reason names the failing check only; field values never surface here. + this.failProtocol(`invalid host challenge: ${invalidReason} origin=${this.relayOrigin}`) return } this.socket?.send( @@ -264,6 +274,7 @@ export class RelayControlClient { return } this.state = 'active' + this.silenceWatchdog.start() this.connectResolve?.(ack.data) this.clearConnectPromise() } @@ -284,6 +295,7 @@ export class RelayControlClient { private handleClose(code: number): void { const wasConnecting = this.state === 'opening' || this.state === 'proving' this.state = 'closed' + this.silenceWatchdog.stop() if (wasConnecting) { this.connectReject?.(new Error(`relay_control_closed_${code}`)) this.clearConnectPromise() diff --git a/src/main/runtime/relay/relay-control-origin.ts b/src/main/runtime/relay/relay-control-origin.ts index 8035e32c1b6..4d42da47977 100644 --- a/src/main/runtime/relay/relay-control-origin.ts +++ b/src/main/runtime/relay/relay-control-origin.ts @@ -71,6 +71,10 @@ export class RelayControlOrigin { return this.activeControl } + hasLiveControl(): boolean { + return this.activeControl?.isLive() ?? false + } + get cellUrl(): string { return this.assignment.cellUrl } @@ -225,6 +229,13 @@ export class RelayControlOrigin { } private activate(control: RelayControlClient, ack: RelayHostHelloAckMessage): void { + // Why: a socket can deliver hello-ack and close in the same ws parser turn. + // That close already ran onClose (removing the control) before this + // continuation, so promoting it would publish a dead control that no close + // event will ever recover. + if (!this.controls.has(control) || !control.isLive()) { + throw new Error('relay_control_closed_before_activation') + } if (ack.generation <= 0) { throw new Error('invalid_relay_generation') } diff --git a/src/main/runtime/relay/relay-control-silence-watchdog.ts b/src/main/runtime/relay/relay-control-silence-watchdog.ts new file mode 100644 index 00000000000..4015dea3dd8 --- /dev/null +++ b/src/main/runtime/relay/relay-control-silence-watchdog.ts @@ -0,0 +1,37 @@ +// The relay pings every 15s and closes a control after 75s of silence; mirror +// that bound so a dead or server-side-unindexed socket cannot stay "active". +export const RELAY_CONTROL_SILENCE_LIMIT_MS = 75_000 + +const CHECK_INTERVAL_MS = 15_000 + +export class RelayControlSilenceWatchdog { + private timer: ReturnType | null = null + private lastInboundAt = 0 + + constructor( + private readonly limitMs: number, + private readonly onSilence: () => void + ) {} + + noteInbound(): void { + this.lastInboundAt = Date.now() + } + + start(): void { + this.lastInboundAt = Date.now() + this.timer = setInterval(() => { + if (Date.now() - this.lastInboundAt > this.limitMs) { + this.stop() + this.onSilence() + } + }, CHECK_INTERVAL_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } +} diff --git a/src/main/runtime/relay/relay-control-url.ts b/src/main/runtime/relay/relay-control-url.ts new file mode 100644 index 00000000000..fbb016dd08d --- /dev/null +++ b/src/main/runtime/relay/relay-control-url.ts @@ -0,0 +1,15 @@ +export function controlWebSocketUrl(cellUrl: string): { origin: string; url: string } { + const parsed = new URL(cellUrl) + if (parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error('relay_cell_url_must_be_an_origin') + } + const origin = parsed.origin + if (parsed.protocol === 'https:') { + parsed.protocol = 'wss:' + } else if (parsed.protocol === 'http:') { + parsed.protocol = 'ws:' + } else { + throw new Error('relay_cell_url_must_use_http') + } + return { origin, url: `${parsed.origin}/v1/host/control` } +} diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index da17f8a1576..59c028b1ab1 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -29,6 +29,8 @@ export type RelayHostProofContext = { previousGeneration?: number resumeRequested: boolean now?: () => number + /** Reports the failing check by name only; never receives field values. */ + onInvalid?: (reason: string) => void } function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { @@ -95,6 +97,7 @@ function validateTranscript( ): boolean { const fields = parseTranscript(transcript) if (!fields || fields.size !== 16) { + context.onInvalid?.('transcript-structure') return false } const now = (context.now ?? Date.now)() @@ -103,28 +106,51 @@ function validateTranscript( const previousGeneration = fields.get('previousGeneration') const expectedPrevious = context.previousGeneration === undefined ? new Uint8Array() : uint64(context.previousGeneration) - return ( - issuedAt !== null && - issuedAt - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= now && - now - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= challenge.expiresAt && - issuedAt <= challenge.expiresAt && - challenge.expiresAt - issuedAt <= MAX_HOST_PROOF_CHALLENGE_WINDOW_MS && - expiresAt === challenge.expiresAt && - equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN)) && - equal(fields.get('version'), new Uint8Array([1])) && - equal(fields.get('relayOrigin'), textEncoder.encode(context.relayOrigin)) && - equal(fields.get('relayEphemeralPublicKey'), relayKey) && - equal(fields.get('challengeNonce'), nonce) && - equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId)) && - equal(fields.get('userId'), textEncoder.encode(context.userId)) && - equal(fields.get('profileId'), textEncoder.encode(context.profileId)) && - equal(fields.get('organizationId'), textEncoder.encode(context.organizationId)) && - equal(fields.get('relayHostId'), textEncoder.encode(context.relayHostId)) && - equal(fields.get('hostPublicKey'), context.hostPublicKey) && - equal(fields.get('assignmentEpoch'), uint64(context.assignmentEpoch)) && - equal(previousGeneration, expectedPrevious) && - equal(fields.get('resumeRequested'), new Uint8Array([context.resumeRequested ? 1 : 0])) - ) + // Main's 30s skew bounds with named-check reporting kept from the incident + // instrumentation; deltas are relative offsets only, never absolute values. + const checks: [string, boolean][] = [ + ['issuedAt-readable', issuedAt !== null], + [ + `issuedAt-not-future(d=${issuedAt === null ? 'n/a' : issuedAt - now}ms)`, + issuedAt === null || issuedAt - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= now + ], + [ + `not-expired(d=${challenge.expiresAt - now}ms)`, + now - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= challenge.expiresAt + ], + ['issuedAt-before-expiry', issuedAt === null || issuedAt <= challenge.expiresAt], + [ + `window(w=${issuedAt === null ? 'n/a' : challenge.expiresAt - issuedAt}ms)`, + issuedAt === null || challenge.expiresAt - issuedAt <= MAX_HOST_PROOF_CHALLENGE_WINDOW_MS + ], + ['expiry-consistent', expiresAt === challenge.expiresAt], + ['protocol', equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN))], + ['version', equal(fields.get('version'), new Uint8Array([1]))], + ['relayOrigin', equal(fields.get('relayOrigin'), textEncoder.encode(context.relayOrigin))], + ['relayEphemeralPublicKey', equal(fields.get('relayEphemeralPublicKey'), relayKey)], + ['challengeNonce', equal(fields.get('challengeNonce'), nonce)], + ['challengeId', equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId))], + ['userId', equal(fields.get('userId'), textEncoder.encode(context.userId))], + ['profileId', equal(fields.get('profileId'), textEncoder.encode(context.profileId))], + [ + 'organizationId', + equal(fields.get('organizationId'), textEncoder.encode(context.organizationId)) + ], + ['relayHostId', equal(fields.get('relayHostId'), textEncoder.encode(context.relayHostId))], + ['hostPublicKey', equal(fields.get('hostPublicKey'), context.hostPublicKey)], + ['assignmentEpoch', equal(fields.get('assignmentEpoch'), uint64(context.assignmentEpoch))], + ['previousGeneration', equal(previousGeneration, expectedPrevious)], + [ + 'resumeRequested', + equal(fields.get('resumeRequested'), new Uint8Array([context.resumeRequested ? 1 : 0])) + ] + ] + const failed = checks.filter(([, ok]) => !ok).map(([name]) => name) + if (failed.length > 0) { + context.onInvalid?.(`transcript:${failed.join('+')}`) + return false + } + return true } export function answerRelayHostChallenge( @@ -139,6 +165,7 @@ export function answerRelayHostChallenge( } const plaintext = nacl.box.open(ciphertext, nonce, relayKey, context.hostSecretKey) if (!plaintext) { + context.onInvalid?.('challenge-box-open') return null } const domain = textEncoder.encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`) diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 29739a7e480..213d27629d6 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -56,6 +56,59 @@ describe('relay HTTP client', () => { expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) }) + it('declares reconnection in the assignment body when hinted', async () => { + const fetch = vi.fn(async () => + Response.json({ + v: 1, + cellUrl: 'https://relay-c1.example', + assignmentEpoch: 4, + lease: 'lease-jwt' + }) + ) + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + reconnect: true, + fetch + }) + ).resolves.toMatchObject({ assignmentEpoch: 4 }) + expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({ + v: 1, + relayHostId: 'AbCdEf0123_-xyZ9', + reconnect: true + }) + }) + + it('retries once unhinted when a rolled-back director rejects the hint', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ error: 'invalid_request' }, { status: 400 })) + .mockResolvedValueOnce( + Response.json({ + v: 1, + cellUrl: 'https://relay-c1.example', + assignmentEpoch: 4, + lease: 'lease-jwt' + }) + ) + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + reconnect: true, + fetch + }) + ).resolves.toMatchObject({ assignmentEpoch: 4 }) + expect(fetch).toHaveBeenCalledTimes(2) + expect(JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))).toEqual({ + v: 1, + relayHostId: 'AbCdEf0123_-xyZ9' + }) + }) + it('aborts a blackholed assignment request so recovery can retry', async () => { const fetch = vi.fn( async (_url, init) => diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index 394f5eee013..0cb370f5ba2 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -116,6 +116,7 @@ export async function requestRelayAssignment(input: { directorUrl: string relayToken: string relayHostId: string + reconnect?: boolean fetch?: typeof globalThis.fetch requestDeadlineMs?: number }): Promise { @@ -129,11 +130,21 @@ export async function requestRelayAssignment(input: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(input.requestDeadlineMs ?? RELAY_HTTP_REQUEST_DEADLINE_MS), - body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) + body: JSON.stringify({ + v: 1, + relayHostId: input.relayHostId, + // Declares likely reconnection so the director can verify and admit + // through its bounded fast lane instead of the placement queue. + ...(input.reconnect ? { reconnect: true } : {}) + }) }) if (!response.ok) { const retryAfterMs = relayRetryAfterMs(response.headers.get('retry-after')) await cancelUnreadResponseBody(response) + if (input.reconnect && response.status === 400) { + // A rolled-back director rejects unknown fields; retry once unhinted. + return await requestRelayAssignment({ ...input, reconnect: false }) + } throw new RelayHttpError('assignment', response.status, retryAfterMs) } const parsed = AssignmentResponseSchema.safeParse(await response.json()) diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index 6736e272121..6f5f5951bf8 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -55,6 +55,10 @@ export class RelayOriginPool { return this.basisOrigins.get(basisConnId)?.availableControl ?? null } + hasLiveControl(): boolean { + return this.activeOrigin?.hasLiveControl() ?? false + } + async openInitial(assignment: RelayAssignment, relayJwt: string): Promise { this.assignment = assignment this.relayJwt = relayJwt @@ -159,6 +163,9 @@ export class RelayOriginPool { directorUrl: this.options.directorUrl, relayToken: this.relayJwt, relayHostId: this.options.relayHostId, + // Recovery always follows an established assignment; the director + // verifies this and admits through its reconnect fast lane. + reconnect: true, fetch: this.options.fetch }) this.assertCurrent() @@ -186,6 +193,12 @@ export class RelayOriginPool { this.scheduleControlRotation() } catch (error) { if (this.isCurrent() && origin === this.activeOrigin) { + // Why: this retry loop ran silently during the 2026-08 incident while + // Director 503 throttling stretched recovery to minutes. + console.warn( + '[relay] control recovery attempt failed:', + error instanceof Error ? error.message : String(error) + ) const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 this.drainRetry.schedule(retryAfterMs, () => this.handleDrain(origin, message)) } diff --git a/src/main/runtime/relay/relay-session-broker.test.ts b/src/main/runtime/relay/relay-session-broker.test.ts index f4c9e7368a2..1fd172c2537 100644 --- a/src/main/runtime/relay/relay-session-broker.test.ts +++ b/src/main/runtime/relay/relay-session-broker.test.ts @@ -47,6 +47,7 @@ vi.mock('./relay-control-client', () => ({ RelayControlClient: class { connect = fakes.controlConnect closeNow = vi.fn() + isLive = vi.fn(() => true) confirmResume = vi.fn().mockResolvedValue({ type: 'device-resume-confirmed', v: 1, @@ -157,6 +158,33 @@ describe('RelaySessionBroker lifecycle ownership', () => { expect(statuses).toEqual(['connecting']) }) + it('fails connect when the control closes before origin activation', async () => { + // Why: a socket can deliver hello-ack and close in the same ws parser turn; + // onClose then fires before the connect promise settles, so nothing may + // publish this control as active. + fakes.controlConnect.mockImplementationOnce(async () => { + fakes.controls[0]!.options.onClose(1006) + return { + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'A'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } satisfies RelayHostHelloAckMessage + }) + const statuses: string[] = [] + + await expect( + RelaySessionBroker.connect(brokerOptions({ onStatus: (status) => statuses.push(status) })) + ).rejects.toThrow('relay_control_closed_before_activation') + + expect(statuses).not.toContain('registered') + expect(statuses.at(-1)).toBe('offline') + await vi.waitFor(() => expect(fakes.transports[0]!.stop).toHaveBeenCalled()) + }) + it('activates a new origin while keeping basis-bound work on the drained origin', async () => { const firstAck: RelayHostHelloAckMessage = { type: 'host-hello-ack', diff --git a/src/main/runtime/relay/relay-session-broker.ts b/src/main/runtime/relay/relay-session-broker.ts index 920bd7b57e9..e2a8402a04c 100644 --- a/src/main/runtime/relay/relay-session-broker.ts +++ b/src/main/runtime/relay/relay-session-broker.ts @@ -77,6 +77,10 @@ export class RelaySessionBroker { return `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` } + isLive(): boolean { + return !this.closed && this.originPool.hasLiveControl() + } + get endpoint(): MobileRelayEndpoint | null { const assignment = this.originPool.activeAssignment if (!assignment) { @@ -204,6 +208,10 @@ export class RelaySessionBroker { directorUrl: this.options.authConfig.relayDirectorUrl, relayToken: authorization.relayToken, relayHostId: this.relayHostId, + // Any previously paired host likely holds a durable assignment; the + // director verifies the claim, and first-ever pairing simply falls + // through to the placement lane. + reconnect: true, fetch: this.options.fetch }) this.assertCurrent() diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 2e38b4848c0..af254ca02e5 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -10,6 +10,7 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isTaskProvider } from '../../../../shared/task-providers' +import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' import { @@ -250,7 +251,10 @@ const UiUpdateFields = z lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), dismissedUpdateNudgeId: NullableString.optional(), - releaseChannelOverride: z.enum(['stable', 'rc', 'hourly']).nullable().optional(), + // Why the predicate rather than an inline z.enum: an enum here is a copy of + // RELEASE_CHANNELS, and a copy that drifts silently rejects the new + // channel's override on its way here — the picker moves, nothing installs. + releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 7c2e3cb7bb9..3d132e82e41 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -49,6 +49,28 @@ describe('hosted review RPC methods', () => { }) }) + it('carries a selected-worktree claim through to the runtime', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getHostedReviewForBranch: vi.fn().mockResolvedValue(null) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS }) + + await dispatcher.dispatch( + makeRequest('hostedReview.forBranch', { + repo: '/repo', + branch: 'feature/selected', + active: true + }) + ) + + // Why: without this the mobile PR sidebar would sit on the card-list pacing + // and take a no-review interval to notice a PR opened elsewhere (#11532). + expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith( + expect.objectContaining({ active: true }) + ) + }) + it('dispatches creation eligibility requests to the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 538c6267cb2..b658c4c74cb 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -6,6 +6,8 @@ const HostedReviewForBranch = z.object({ repo: requiredString('Missing repo selector'), branch: requiredString('Missing branch'), currentHeadOid: z.string().nullable().optional(), + // Only the caller's selected worktree; the host caps how many earn the fast tier. + active: z.boolean().optional(), linkedGitHubPR: z.number().int().positive().nullable().optional(), fallbackGitHubPR: z.number().int().positive().nullable().optional(), linkedGitLabMR: z.number().int().positive().nullable().optional(), @@ -54,6 +56,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ repoSelector: params.repo, branch: params.branch, currentHeadOid: params.currentHeadOid ?? null, + ...(params.active === true ? { active: true } : {}), linkedGitHubPR: params.linkedGitHubPR ?? null, ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), linkedGitLabMR: params.linkedGitLabMR ?? null, diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts index 5324695c090..fb7b09d8ceb 100644 --- a/src/main/runtime/rpc/schemas.ts +++ b/src/main/runtime/rpc/schemas.ts @@ -68,8 +68,6 @@ export const BrowserTarget = z.object({ page: OptionalString }) -export type BrowserTargetInput = z.infer - export function requiredString(message: string) { return z .unknown() diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index eac1820a047..b51f292c29c 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -424,6 +424,56 @@ describe('killAllProcessesForWorktree', () => { expect(result.runtimeStopped).toBe(3) }) + it('forwards resolvedWorktreeId so an orphan sweep skips selector resolution', async () => { + const stopTerminalsForWorktree = vi.fn().mockResolvedValue({ stopped: 2 }) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { + runtime, + localProvider, + resolvedWorktreeId: 'w1' + }) + + // Without this the sweep resolves a selector whose repo is gone and throws selector_not_found. + expect(stopTerminalsForWorktree).toHaveBeenCalledWith('w1', { + deadline: expect.any(Number), + stopPty: expect.any(Function), + resolvedWorktreeId: 'w1' + }) + expect(result.runtimeStopped).toBe(2) + }) + + it('can restrict an orphan runtime sweep to its SSH connection', async () => { + const stopTerminalsForWorktree = vi.fn().mockResolvedValue({ stopped: 1 }) + const runtime = { + stopTerminalsForWorktree + } as unknown as Parameters[1]['runtime'] + const localProvider = createProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([]) + + await killAllProcessesForWorktree('w1', { + runtime, + localProvider, + resolvedWorktreeId: 'w1', + resolvedConnectionId: 'ssh-1', + includeProviderInventory: false, + includeLocalRegistry: false + }) + + expect(stopTerminalsForWorktree).toHaveBeenCalledWith('w1', { + deadline: expect.any(Number), + stopPty: expect.any(Function), + resolvedWorktreeId: 'w1', + resolvedConnectionId: 'ssh-1' + }) + expect(localProvider.listProcesses).not.toHaveBeenCalled() + }) + it('claims duplicate provider and registry PTY ids only once', async () => { const localProvider = createProviderStub(async () => [ { id: 'w1@@same', cwd: '/tmp/w1', title: 'shell' } diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 7f25d67937a..64b6a09703f 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -11,10 +11,17 @@ const WORKTREE_TEARDOWN_CONCURRENCY = 32 export type WorktreeTeardownDeps = { runtime?: OrcaRuntimeService + /** Authoritative id for callers whose selector no longer resolves (orphaned workspace). */ + resolvedWorktreeId?: string + /** SSH connection owning `resolvedWorktreeId`; prevents same-id cross-host graph matches. */ + resolvedConnectionId?: string + /** Runtime environment owning a mirrored `resolvedWorktreeId`. */ + resolvedRuntimeEnvironmentId?: string localProvider: IPtyProvider onPtyStopped?: (ptyId: string) => void timeoutMs?: number requirePhysicalStop?: boolean + includeProviderInventory?: boolean includeLocalRegistry?: boolean } @@ -62,11 +69,6 @@ export async function killAllProcessesForWorktree( worktreeId: string, deps: WorktreeTeardownDeps ): Promise { - const result: WorktreeTeardownResult = { - runtimeStopped: 0, - providerStopped: 0, - registryStopped: 0 - } const deadline = Date.now() + Math.max(1, deps.timeoutMs ?? WORKTREE_PROCESS_SWEEP_TIMEOUT_MS) const deadlineError = new Error(`Timed out waiting for physical PTY teardown: ${worktreeId}`) const stopAttempts = new Map>() @@ -97,7 +99,18 @@ export async function killAllProcessesForWorktree( // destructive removal closed. const runtimeSweep = deps.runtime ? settleBeforeDeadline( - () => deps.runtime!.stopTerminalsForWorktree(worktreeId, { deadline, stopPty }), + () => + deps.runtime!.stopTerminalsForWorktree(worktreeId, { + deadline, + stopPty, + ...(deps.resolvedWorktreeId ? { resolvedWorktreeId: deps.resolvedWorktreeId } : {}), + ...(deps.resolvedConnectionId + ? { resolvedConnectionId: deps.resolvedConnectionId } + : {}), + ...(deps.resolvedRuntimeEnvironmentId + ? { resolvedRuntimeEnvironmentId: deps.resolvedRuntimeEnvironmentId } + : {}) + }), { stopped: 0 }, deadline, deps.requirePhysicalStop ? deadlineError : undefined, @@ -108,20 +121,23 @@ export async function killAllProcessesForWorktree( ) ) : Promise.resolve({ stopped: 0 }) - const providerSweep = settleBeforeDeadline( - () => - sweepProviderByPrefix( - worktreeId, - deps.localProvider, - deadline, - stopPty, - deps.onPtyStopped, - deps.requirePhysicalStop - ), - 0, - deadline, - deps.requirePhysicalStop ? deadlineError : undefined - ) + const providerSweep = + deps.includeProviderInventory === false + ? Promise.resolve(0) + : settleBeforeDeadline( + () => + sweepProviderByPrefix( + worktreeId, + deps.localProvider, + deadline, + stopPty, + deps.onPtyStopped, + deps.requirePhysicalStop + ), + 0, + deadline, + deps.requirePhysicalStop ? deadlineError : undefined + ) const registrySweep = deps.includeLocalRegistry === false ? Promise.resolve(0) @@ -143,9 +159,6 @@ export async function killAllProcessesForWorktree( providerSweep, registrySweep ]) - result.runtimeStopped = runtimeResult.stopped - result.providerStopped = providerStopped - result.registryStopped = registryStopped if (deps.requirePhysicalStop) { const stopResults = await Promise.all( [...stopAttempts].map(async ([ptyId, stopped]) => [ptyId, await stopped] as const) @@ -162,7 +175,7 @@ export async function killAllProcessesForWorktree( } } - return result + return { runtimeStopped: runtimeResult.stopped, providerStopped, registryStopped } } async function verifyFailedPtysExited( diff --git a/src/main/shell-ready-marker-scanner.ts b/src/main/shell-ready-marker-scanner.ts index d0633fa1393..0e6250e1c4e 100644 --- a/src/main/shell-ready-marker-scanner.ts +++ b/src/main/shell-ready-marker-scanner.ts @@ -1,5 +1,4 @@ export const SHELL_READY_MARKER_PREFIX = '\x1b]777;orca-shell-ready' -export const SHELL_READY_MARKER = `${SHELL_READY_MARKER_PREFIX}\x07` export type ShellReadyScanState = { matchPos: number diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 884b72f90de..ae5e5623a97 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -12,6 +12,7 @@ const { getProjectSlugMock, getPRForBranchOutcomeMock, getRepoSlugMock, + getGitHubPRLookupRateLimitBlockMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -25,12 +26,16 @@ const { getProjectSlugMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), getRepoSlugMock: vi.fn(), + getGitHubPRLookupRateLimitBlockMock: vi.fn(async () => null), getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: getMergeRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no MR found". + getMergeRequestForBranchOrThrow: getMergeRequestForBranchMock, getMergeRequest: vi.fn() })) @@ -41,7 +46,8 @@ vi.mock('../gitlab/merge-request-creation', () => ({ vi.mock('../github/client', () => ({ createGitHubPullRequest: createGitHubPullRequestMock, getRepoSlug: getRepoSlugMock, - getPRForBranchOutcome: getPRForBranchOutcomeMock + getPRForBranchOutcome: getPRForBranchOutcomeMock, + getGitHubPRLookupRateLimitBlock: getGitHubPRLookupRateLimitBlockMock })) vi.mock('../github/github-enterprise-repository', () => ({ @@ -103,6 +109,8 @@ describe('forge provider interface', () => { getPRForBranchOutcomeMock.mockReset() getRepoSlugMock.mockReset() getEnterpriseGitHubRepoSlugMock.mockReset() + getGitHubPRLookupRateLimitBlockMock.mockReset() + getGitHubPRLookupRateLimitBlockMock.mockResolvedValue(null) }) it('preserves the existing hosted provider detection order', async () => { @@ -377,4 +385,48 @@ describe('forge provider interface', () => { }) ).rejects.toThrow(/network/) }) + + it('refuses a GitHub branch lookup while the rate-limit budget is exhausted (#11532)', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValueOnce({ + resetAt: 1_800_000_000 + } as never) + + await expect( + getForgeProviderById('github').getReviewForBranch({ + repoPath: '/repo', + connectionId: null, + branch: 'feature/x' + }) + // Throwing (not null) keeps a low budget from reading as "no pull request". + ).rejects.toThrow(/rate_limited/) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + }) + + it('refuses a GitHub lookup by number while the rate-limit budget is exhausted (#11532)', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValueOnce({ + resetAt: 1_800_000_000 + } as never) + + await expect( + getForgeProviderById('github').getReviewByNumber({ + repoPath: '/repo', + connectionId: null, + number: 42 + }) + ).rejects.toThrow(/rate_limited/) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + }) + + it('does not gate non-GitHub providers on the GitHub rate limit', async () => { + getGitHubPRLookupRateLimitBlockMock.mockResolvedValue({ resetAt: 1_800_000_000 } as never) + getMergeRequestForBranchMock.mockResolvedValue(null) + + await expect( + getForgeProviderById('gitlab').getReviewForBranch({ + repoPath: '/repo', + connectionId: null, + branch: 'feature/x' + }) + ).resolves.toBeNull() + }) }) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index d4967486ca0..19526ad71fa 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -21,7 +21,12 @@ import { getGiteaRepoSlug } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' -import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' +import { + createGitHubPullRequest, + getGitHubPRLookupRateLimitBlock, + getPRForBranchOutcome, + getRepoSlug +} from '../github/client' import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -122,6 +127,29 @@ function unwrapGitHubPRForBranchOutcome( return outcome.kind === 'found' ? mapGitHubReview(outcome.pr) : null } +/** + * Why (#11532): hosted-review lookups reach GitHub outside the PR refresh + * coordinator's paced queue, so they need the same rate-limit floor. Throwing + * (rather than returning null) keeps a low budget from reading as "no pull + * request" — callers preserve the last known review and back off. + */ +async function assertGitHubReviewRateLimitBudget( + input: ForgeProviderRepositoryContext +): Promise { + const block = await getGitHubPRLookupRateLimitBlock( + input.repoPath, + input.connectionId, + getHostedReviewLocalGitOptions(input) + ) + if (block) { + throw new Error( + `GitHub PR lookup failed (rate_limited): GitHub rate limit is low. Try again after ${new Date( + block.resetAt * 1000 + ).toLocaleTimeString()}.` + ) + } +} + const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, @@ -131,6 +159,7 @@ const gitHubForgeProvider = { resolveRepository: async (context) => getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { + await assertGitHubReviewRateLimitBudget(input) const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null const executionArgs = hostedReviewExecutionArgs(input) @@ -149,6 +178,7 @@ const gitHubForgeProvider = { return unwrapGitHubPRForBranchOutcome(outcome) }, async getReviewByNumber(input) { + await assertGitHubReviewRateLimitBudget(input) const executionArgs = hostedReviewExecutionArgs(input) const outcome = executionArgs.length > 0 diff --git a/src/main/source-control/hosted-review-active-branch-claims.ts b/src/main/source-control/hosted-review-active-branch-claims.ts new file mode 100644 index 00000000000..ffcff78acc6 --- /dev/null +++ b/src/main/source-control/hosted-review-active-branch-claims.ts @@ -0,0 +1,45 @@ +import { ACTIVE_CLAIM_TTL_MS, MAX_ACTIVE_BRANCHES } from './hosted-review-refresh-pacing' + +/** + * The branches callers report as their current selection (#11532). + * + * Split from the answer cache because it is a different question: not "what is + * this branch's review" but "is anyone looking at it", which is what buys the + * per-minute tier. + */ +const activeClaims = new Map() + +/** + * Records the caller's current selection, reporting whether the branch was not + * already active. Claims are least-recently-used so the fast tier stays bounded + * no matter how many a client asserts. + */ +export function noteActiveClaim(key: string): boolean { + const now = Date.now() + for (const [candidate, claimedAt] of activeClaims) { + if (now - claimedAt > ACTIVE_CLAIM_TTL_MS) { + activeClaims.delete(candidate) + } + } + const wasActive = activeClaims.has(key) + activeClaims.delete(key) + activeClaims.set(key, now) + while (activeClaims.size > MAX_ACTIVE_BRANCHES) { + const oldest = activeClaims.keys().next().value + if (oldest === undefined) { + break + } + activeClaims.delete(oldest) + } + return !wasActive +} + +export function isActiveBranch(key: string): boolean { + const claimedAt = activeClaims.get(key) + return claimedAt !== undefined && Date.now() - claimedAt <= ACTIVE_CLAIM_TTL_MS +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewActiveClaimsForTests(): void { + activeClaims.clear() +} diff --git a/src/main/source-control/hosted-review-bitbucket.integration.test.ts b/src/main/source-control/hosted-review-bitbucket.integration.test.ts index 603ec231a7f..588d37d6b04 100644 --- a/src/main/source-control/hosted-review-bitbucket.integration.test.ts +++ b/src/main/source-control/hosted-review-bitbucket.integration.test.ts @@ -4,9 +4,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetBitbucketRepoRefCache } from '../bitbucket/repository-ref' import { getHostedReviewForBranch } from './hosted-review' +import { __resetHostedReviewBranchCacheForTests } from './hosted-review-branch-cache' const execFileAsync = promisify(execFile) const OLD_ENV = process.env @@ -28,11 +29,14 @@ describe('Bitbucket hosted review integration', () => { delete process.env.ORCA_BITBUCKET_EMAIL delete process.env.ORCA_BITBUCKET_API_TOKEN _resetBitbucketRepoRefCache() + __resetHostedReviewBranchCacheForTests() }) afterEach(() => { + vi.useRealTimers() process.env = OLD_ENV _resetBitbucketRepoRefCache() + __resetHostedReviewBranchCacheForTests() }) it('resolves a Bitbucket PR through real git remote parsing and HTTP API calls', async () => { @@ -118,4 +122,80 @@ describe('Bitbucket hosted review integration', () => { }) } }) + + it('does not cache a transient API failure as a definitive no-review result', async () => { + vi.useFakeTimers({ now: Date.now() }) + let pullRequestCalls = 0 + let buildStatusCalls = 0 + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`) + if (url.pathname === '/2.0/repositories/team/repo/pullrequests') { + pullRequestCalls += 1 + if (pullRequestCalls === 1) { + res.writeHead(503, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'temporary outage' })) + return + } + sendJson(res, { + values: [ + { + id: 13, + title: 'Recovered Bitbucket branch', + state: 'OPEN', + updated_on: '2026-05-15T00:00:00Z', + links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/13' } }, + source: { branch: { name: 'feature/recovery' }, commit: { hash: 'def456' } } + } + ] + }) + return + } + + if (url.pathname === '/2.0/repositories/team/repo/commit/def456/statuses/build') { + buildStatusCalls += 1 + sendJson(res, { values: [{ state: 'SUCCESSFUL' }] }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'not found' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + const repoPath = await mkdtemp(join(tmpdir(), 'orca-bitbucket-review-recovery-')) + try { + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP server address') + } + + process.env.ORCA_BITBUCKET_API_BASE_URL = `http://127.0.0.1:${address.port}/2.0` + await execFileAsync('git', ['init'], { cwd: repoPath }) + await execFileAsync('git', ['remote', 'add', 'origin', 'git@bitbucket.org:team/repo.git'], { + cwd: repoPath + }) + + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/recovery' }) + ).rejects.toThrow('HTTP 503') + + vi.advanceTimersByTime(60_001) + await expect( + getHostedReviewForBranch({ repoPath, branch: 'refs/heads/feature/recovery' }) + ).resolves.toMatchObject({ + provider: 'bitbucket', + number: 13, + title: 'Recovered Bitbucket branch', + state: 'open' + }) + expect(pullRequestCalls).toBe(2) + // The failed lookup never reaches build status, so the recovery owns this call. + expect(buildStatusCalls).toBe(1) + } finally { + await rm(repoPath, { recursive: true, force: true }) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) }) diff --git a/src/main/source-control/hosted-review-branch-cache.test.ts b/src/main/source-control/hosted-review-branch-cache.test.ts new file mode 100644 index 00000000000..a9b509873c3 --- /dev/null +++ b/src/main/source-control/hosted-review-branch-cache.test.ts @@ -0,0 +1,913 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + __resetHostedReviewBranchCacheForTests, + invalidateHostedReviewBranchCache, + withHostedReviewBranchCache +} from './hosted-review-branch-cache' +import { + HOSTED_REVIEW_LOOKUP_DEADLINE_MS, + LOOKUP_BACKOFF_MAX_MS, + MAX_BRANCH_MAP_ENTRIES, + MAX_DETACHED_LOOKUPS, + MAX_INFLIGHT_LOOKUPS, + MAX_UNSETTLED_LOOKUP_KEYS, + MAX_UNSETTLED_LOOKUPS_PER_KEY +} from './hosted-review-refresh-pacing' + +const identity = { repoPath: '/repo', connectionId: null, branch: 'feature/x' } +const START = 1_000_000 + +/** A lookup that never settles — the wedged provider this file's deadline exists for. */ +function stuckLookup() { + let resolve: (review: HostedReviewInfo | null) => void = () => {} + let reject: (error: unknown) => void = () => {} + const lookup = vi.fn( + () => + new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + ) + return { lookup, resolve: (review) => resolve(review), reject: (error) => reject(error) } +} + +/** + * Runs `evictedLookup` for the shared identity, drops its in-flight record via + * the size cap alone — no deadline, so it is a straggler that never timed out — + * and leaves a fresh lookup owning the key. + */ +function evictInflightRecord(evictedLookup: () => Promise) { + const swallow = (promise: Promise): void => { + void promise.catch(() => {}) + } + swallow(withHostedReviewBranchCache(identity, { headOid: null }, evictedLookup)) + + const filler = stuckLookup() + for (let index = 0; index < MAX_INFLIGHT_LOOKUPS; index += 1) { + swallow( + withHostedReviewBranchCache( + { ...identity, branch: `feature/${index}` }, + { headOid: null }, + filler.lookup + ) + ) + } + + const successor = stuckLookup() + const request = withHostedReviewBranchCache(identity, { headOid: null }, successor.lookup) + return { request, resolve: successor.resolve, reject: successor.reject } +} + +const openReview: HostedReviewInfo = { + provider: 'github', + number: 7, + title: 'Open PR', + state: 'open', + url: 'https://github.com/acme/orca/pull/7', + status: 'success', + updatedAt: '2026-07-31T00:00:00.000Z', + mergeable: 'MERGEABLE' +} + +const mergedReview: HostedReviewInfo = { ...openReview, state: 'merged' } + +describe('hosted review branch cache (#11532)', () => { + beforeEach(() => { + __resetHostedReviewBranchCacheForTests() + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('holds a no-review answer far longer than a poll interval', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 5 * 60_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('keeps the no-review answer while the branch head moves', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + vi.setSystemTime(1_000_000 + 60_000) + await withHostedReviewBranchCache(identity, { headOid: 'bbb' }, lookup) + + // A commit is not evidence that a review was opened, so it must not defeat + // the long interval — that is what kept busy worktrees polling every minute. + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('refreshes a found review at the caller cadence', async () => { + const lookup = vi.fn(async () => openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 30_000) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('drops a merged review once the inspected head moves off it', async () => { + const lookup = vi.fn(async () => mergedReview) + + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + + await withHostedReviewBranchCache(identity, { headOid: 'bbb' }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('collapses concurrent callers onto one lookup', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const lookup = vi.fn( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + + const first = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + const second = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + resolveLookup(openReview) + + await expect(first).resolves.toEqual(openReview) + await expect(second).resolves.toEqual(openReview) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('separates lookups that differ only by linked review number', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache({ ...identity, linkedGitHubPR: 1 }, { headOid: null }, lookup) + await withHostedReviewBranchCache({ ...identity, linkedGitHubPR: 2 }, { headOid: null }, lookup) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('backs a failing branch off instead of re-asking every poll', async () => { + const lookup = vi.fn(async () => { + throw new Error('rate limited') + }) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'rate limited' + ) + vi.setSystemTime(1_000_000 + 30_000) + // Nothing cached, so the caller must still hear a failure — but no API call. + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + /backing off/ + ) + expect(lookup).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000_000 + 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'rate limited' + ) + expect(lookup).toHaveBeenCalledTimes(2) + + // The second failure doubles the window. + vi.setSystemTime(1_000_000 + 60_001 + 60_000) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + /backing off/ + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('serves the last known review from the failure itself, not only the backoff', async () => { + const lookup = vi + .fn<() => Promise>() + .mockResolvedValueOnce(openReview) + .mockRejectedValueOnce(new Error('transient')) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 60_001) + // The review must not blink out on the first failure and reappear on the next + // poll once the backoff window is what serves it. + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + + vi.setSystemTime(1_000_000 + 60_001 + 1_000) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('resets the escalation once a lookup succeeds', async () => { + const lookup = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('first')) + .mockResolvedValueOnce(openReview) + .mockRejectedValueOnce(new Error('second')) + .mockResolvedValue(mergedReview) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).rejects.toThrow( + 'first' + ) + vi.setSystemTime(1_000_000 + 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + + // The success clears the counter, so the next failure starts at the base + // window again rather than resuming a doubled one. That failure is served + // from the stale entry, but it still counts. + vi.setSystemTime(1_000_000 + 2 * 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + vi.setSystemTime(1_000_000 + 3 * 60_001) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + mergedReview + ) + expect(lookup).toHaveBeenCalledTimes(4) + }) + + it('retires a cached no-review answer when Orca opens a review', async () => { + const lookup = vi + .fn<() => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('discards a lookup that was already in flight when Orca opened a review', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const lookup = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + .mockResolvedValue(openReview) + + const inflight = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + // The poll started before the review existed, so its "no review" answer is + // older than the invalidation and must not be cached back over it. + resolveLookup(null) + await expect(inflight).resolves.toBeNull() + + await expect(withHostedReviewBranchCache(identity, { headOid: null }, lookup)).resolves.toEqual( + openReview + ) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('leaves another repo in-flight lookup cacheable across an invalidation', async () => { + let resolveLookup: (value: HostedReviewInfo | null) => void = () => {} + const other = { ...identity, repoPath: '/other' } + const lookup = vi.fn<() => Promise>().mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + + const inflight = withHostedReviewBranchCache(other, { headOid: null }, lookup) + invalidateHostedReviewBranchCache('/repo', null) + resolveLookup(null) + await inflight + + await withHostedReviewBranchCache(other, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('scopes invalidation to one repo', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + await withHostedReviewBranchCache( + { ...identity, repoPath: '/other' }, + { headOid: null }, + lookup + ) + expect(lookup).toHaveBeenCalledTimes(2) + + invalidateHostedReviewBranchCache('/other', null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + await withHostedReviewBranchCache( + { ...identity, repoPath: '/other' }, + { headOid: null }, + lookup + ) + expect(lookup).toHaveBeenCalledTimes(3) + }) + + it('keeps SSH and local repos with the same path apart', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + await withHostedReviewBranchCache( + { ...identity, connectionId: 'ssh-1' }, + { headOid: null }, + lookup + ) + + expect(lookup).toHaveBeenCalledTimes(2) + }) + + describe('selected-worktree tier', () => { + it('re-checks the selected branch every minute while the card list waits', async () => { + const selected = vi.fn(async () => null) + const listed = vi.fn(async () => null) + const other = { ...identity, branch: 'feature/y' } + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, selected) + await withHostedReviewBranchCache(other, { headOid: null }, listed) + + vi.setSystemTime(1_000_000 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, selected) + await withHostedReviewBranchCache(other, { headOid: null }, listed) + + expect(selected).toHaveBeenCalledTimes(2) + expect(listed).toHaveBeenCalledTimes(1) + }) + + it('retires a cached no-review answer when a branch becomes the selection', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 1_000) + + // Selecting the worktree is the user asking whether a review exists yet. + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + + // Staying on it does not re-ask; the minute interval takes over. + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('keeps a found review when a branch becomes the selection', async () => { + const lookup = vi.fn(async () => openReview) + + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + vi.setSystemTime(1_000_000 + 1_000) + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + + // Only the long no-review answer is worth spending a call to retire. + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('caps the fast tier so a caller cannot promote a whole list', async () => { + const lookup = vi.fn(async () => null) + const branchAt = (index: number) => ({ ...identity, branch: `feature/${index}` }) + + // One more claim than the cap allows, so the first claim is evicted. + for (let index = 0; index <= 8; index += 1) { + await withHostedReviewBranchCache(branchAt(index), { headOid: null, active: true }, lookup) + } + expect(lookup).toHaveBeenCalledTimes(9) + + vi.setSystemTime(1_000_000 + 60_001) + // The evicted branch is back on card pacing; the newest claim is not. + await withHostedReviewBranchCache(branchAt(0), { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(9) + await withHostedReviewBranchCache(branchAt(8), { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(10) + }) + + it('paces a card poll of the selected branch at the selection interval', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + vi.setSystemTime(1_000_000 + 60_001) + + // Freshness is a property of the branch, not of which surface asked. + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + + it('returns a lapsed selection to card pacing', async () => { + const lookup = vi.fn(async () => null) + + await withHostedReviewBranchCache(identity, { headOid: null, active: true }, lookup) + // Nothing re-asserted the selection for a full no-review interval. + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + + vi.setSystemTime(1_000_000 + 15 * 60_000 + 1 + 60_001) + await withHostedReviewBranchCache(identity, { headOid: null }, lookup) + expect(lookup).toHaveBeenCalledTimes(2) + }) + }) + + describe('in-flight deadline (P1-D)', () => { + it('releases a stuck lookup at the deadline instead of pinning the branch', async () => { + const { lookup } = stuckLookup() + + const request = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + const rejects = expect(request).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + // The dead lookup is no longer joinable, so the branch can recover in + // session rather than staying pinned for the life of the process. + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, lookup) + ).rejects.toThrow(/backing off/) + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('lets a later poll succeed after a lookup times out', async () => { + const { lookup } = stuckLookup() + + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + await vi.advanceTimersByTimeAsync(60_001) + const recovered = vi.fn(async () => openReview) + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, recovered) + ).resolves.toEqual(openReview) + expect(recovered).toHaveBeenCalledTimes(1) + }) + + it('keeps the last known review when a refresh times out', async () => { + await withHostedReviewBranchCache(identity, { headOid: null }, async () => openReview) + vi.setSystemTime(START + 60_001) + + const { lookup } = stuckLookup() + const request = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + + // A wedged refresh must not blank the card any more than a failing one does. + await expect(request).resolves.toEqual(openReview) + }) + + it('adopts a lookup that lands after its deadline', async () => { + const { lookup, resolve } = stuckLookup() + + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + resolve(openReview) + await vi.advanceTimersByTimeAsync(0) + + // The detached call still cost the quota, so its answer is worth keeping: + // a slow-but-alive host converges instead of failing on every poll. + const next = vi.fn(async () => null) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + openReview + ) + expect(next).not.toHaveBeenCalled() + }) + + it('keeps escalating when a lookup only ever answers past its deadline', async () => { + const first = stuckLookup() + const firstRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, first.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await firstRejects + + // A second timeout doubles the window to 120s, so the backoff outlives the + // found-review TTL — that is what makes the escalation observable. + await vi.advanceTimersByTimeAsync(60_001) + const second = stuckLookup() + const secondRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, second.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await secondRejects + + second.resolve(openReview) + await vi.advanceTimersByTimeAsync(0) + + // The answer is still adopted — the call cost the quota either way — but a + // host that only ever answers after the deadline is not healthy, so it must + // not restart its escalation and be re-asked at the base window forever. + await vi.advanceTimersByTimeAsync(60_001) + const recovered = vi.fn(async () => mergedReview) + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, recovered) + ).resolves.toEqual(openReview) + expect(recovered).not.toHaveBeenCalled() + }) + + it('does not let a timed-out null wipe the open review it was refreshing', async () => { + await withHostedReviewBranchCache(identity, { headOid: null }, async () => openReview) + await vi.advanceTimersByTimeAsync(60_001) + + const stale = stuckLookup() + const request = withHostedReviewBranchCache(identity, { headOid: null }, stale.lookup) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await expect(request).resolves.toEqual(openReview) + + stale.resolve(null) + await vi.advanceTimersByTimeAsync(0) + + // The refresh never outranked the answer its own callers were served, so a + // null from a wedged host must not blank the card for a no-review interval. + const next = vi.fn(async () => openReview) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + openReview + ) + expect(next).not.toHaveBeenCalled() + }) + + it('does not let a timed-out null short-circuit the lookup replacing it', async () => { + const stale = stuckLookup() + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, stale.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + await vi.advanceTimersByTimeAsync(60_001) + const replacement = stuckLookup() + const pending = withHostedReviewBranchCache(identity, { headOid: null }, replacement.lookup) + + stale.resolve(null) + await vi.advanceTimersByTimeAsync(0) + + // A stored null reads as a fresh "no review" for the whole no-review + // interval, so it would answer callers ahead of the running lookup. + const joiner = vi.fn(async () => null) + const joined = withHostedReviewBranchCache(identity, { headOid: null }, joiner) + + replacement.resolve(openReview) + await expect(pending).resolves.toEqual(openReview) + await expect(joined).resolves.toEqual(openReview) + expect(joiner).not.toHaveBeenCalled() + }) + + it('lets a newer straggler supersede an older one that landed first', async () => { + const first = stuckLookup() + const firstRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, first.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await firstRejects + + await vi.advanceTimersByTimeAsync(60_001) + const second = stuckLookup() + const secondRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, second.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await secondRejects + + // The older attempt answers first, so landing order says nothing about which + // answer is current — only which lookup started later does. + first.resolve(null) + await vi.advanceTimersByTimeAsync(0) + second.resolve(openReview) + await vi.advanceTimersByTimeAsync(0) + + const next = vi.fn(async () => null) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + openReview + ) + expect(next).not.toHaveBeenCalled() + }) + + it('does not let an older straggler overwrite a newer one that landed first', async () => { + const first = stuckLookup() + const firstRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, first.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await firstRejects + + await vi.advanceTimersByTimeAsync(60_001) + const second = stuckLookup() + const secondRejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, second.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await secondRejects + + // Both are detached, so the one that stores last is whichever host unwedged + // last — the answer that has to survive is the one asked most recently. + second.resolve(openReview) + await vi.advanceTimersByTimeAsync(0) + first.resolve(null) + await vi.advanceTimersByTimeAsync(0) + + const next = vi.fn(async () => null) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + openReview + ) + expect(next).not.toHaveBeenCalled() + }) + + it('does not let a straggler overwrite an answer newer than itself', async () => { + const stale = stuckLookup() + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, stale.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + await vi.advanceTimersByTimeAsync(60_001) + await withHostedReviewBranchCache(identity, { headOid: null }, async () => mergedReview) + + stale.resolve(openReview) + await vi.advanceTimersByTimeAsync(0) + + const next = vi.fn(async () => null) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + mergedReview + ) + expect(next).not.toHaveBeenCalled() + }) + + it('does not let a straggler evict the lookup that replaced it', async () => { + const stale = stuckLookup() + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, stale.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + + await vi.advanceTimersByTimeAsync(60_001) + const replacement = stuckLookup() + const first = withHostedReviewBranchCache(identity, { headOid: null }, replacement.lookup) + + // The straggler settling must clear its own record, not the replacement's — + // otherwise the next caller starts a duplicate provider call. + stale.reject(new Error('late failure')) + await vi.advanceTimersByTimeAsync(0) + + const second = withHostedReviewBranchCache(identity, { headOid: null }, replacement.lookup) + replacement.resolve(openReview) + + await expect(first).resolves.toEqual(openReview) + await expect(second).resolves.toEqual(openReview) + expect(replacement.lookup).toHaveBeenCalledTimes(1) + }) + + it('releases a caller by wall clock when the deadline timer never fires', async () => { + const { lookup } = stuckLookup() + + const request = withHostedReviewBranchCache(identity, { headOid: null }, lookup) + const rejects = expect(request).rejects.toThrow(/timed out/) + // Why: main-process timers are suspended across a system sleep, so age — + // not setTimeout — is what has to bound the pin. + vi.setSystemTime(START + HOSTED_REVIEW_LOOKUP_DEADLINE_MS + 1) + + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, lookup) + ).rejects.toThrow(/backing off/) + await rejects + expect(lookup).toHaveBeenCalledTimes(1) + }) + + it('bounds the in-flight map independently of the completed cache', async () => { + const { lookup } = stuckLookup() + const branchAt = (index: number) => ({ ...identity, branch: `feature/${index}` }) + const swallow = (promise: Promise): void => { + void promise.catch(() => {}) + } + + for (let index = 0; index <= MAX_INFLIGHT_LOOKUPS; index += 1) { + swallow(withHostedReviewBranchCache(branchAt(index), { headOid: null }, lookup)) + } + expect(lookup).toHaveBeenCalledTimes(MAX_INFLIGHT_LOOKUPS + 1) + + // The oldest stuck record was evicted, so its branch is no longer joinable. + swallow(withHostedReviewBranchCache(branchAt(0), { headOid: null }, lookup)) + expect(lookup).toHaveBeenCalledTimes(MAX_INFLIGHT_LOOKUPS + 2) + + // The newest is still tracked, so concurrent callers still collapse onto it. + swallow( + withHostedReviewBranchCache(branchAt(MAX_INFLIGHT_LOOKUPS), { headOid: null }, lookup) + ) + expect(lookup).toHaveBeenCalledTimes(MAX_INFLIGHT_LOOKUPS + 2) + }) + + it('does not let a cap-evicted straggler clobber the answer that replaced it', async () => { + const evicted = stuckLookup() + const successor = evictInflightRecord(evicted.lookup) + + // The evicted lookup never timed out, so it is a straggler on the strength + // of the eviction alone — the successor still owns the key. + evicted.resolve(mergedReview) + await vi.advanceTimersByTimeAsync(0) + + // Stored, its stale answer would read as fresh and be served ahead of the + // lookup that actually owns the branch. + const joiner = vi.fn(async () => null) + const joined = withHostedReviewBranchCache(identity, { headOid: null }, joiner) + + successor.resolve(openReview) + await expect(successor.request).resolves.toEqual(openReview) + await expect(joined).resolves.toEqual(openReview) + expect(joiner).not.toHaveBeenCalled() + }) + + it('does not let a cap-evicted straggler double-count its successor failure', async () => { + const evicted = stuckLookup() + const successor = evictInflightRecord(evicted.lookup) + + evicted.reject(new Error('late failure')) + await vi.advanceTimersByTimeAsync(0) + + successor.reject(new Error('successor failure')) + await expect(successor.request).rejects.toThrow('successor failure') + + // One failure is one backoff doubling: the straggler's rejection must not + // stretch the window the live retry is waiting on. + await vi.advanceTimersByTimeAsync(60_001) + const recovered = vi.fn(async () => openReview) + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, recovered) + ).resolves.toEqual(openReview) + expect(recovered).toHaveBeenCalledTimes(1) + }) + + it('stops asking once a branch has stranded its cap of unsettled lookups', async () => { + const wedged = stuckLookup() + for (let attempt = 0; attempt < MAX_UNSETTLED_LOOKUPS_PER_KEY; attempt += 1) { + const rejects = expect( + withHostedReviewBranchCache(identity, { headOid: null }, wedged.lookup) + ).rejects.toThrow(/timed out/) + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + await rejects + // Past the longest backoff, so only the detached cap can hold the branch. + await vi.advanceTimersByTimeAsync(LOOKUP_BACKOFF_MAX_MS + 1) + } + expect(wedged.lookup).toHaveBeenCalledTimes(MAX_UNSETTLED_LOOKUPS_PER_KEY) + + // Nothing can cancel the stranded calls, so a third would leak another one + // for the life of the process rather than recover anything. + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, wedged.lookup) + ).rejects.toThrow(/never answered/) + expect(wedged.lookup).toHaveBeenCalledTimes(MAX_UNSETTLED_LOOKUPS_PER_KEY) + + // A settling lookup frees its slot, so a host that recovers is asked again. + wedged.resolve(openReview) + await vi.advanceTimersByTimeAsync(60_001) + const recovered = vi.fn(async () => mergedReview) + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, recovered) + ).resolves.toEqual(mergedReview) + expect(recovered).toHaveBeenCalledTimes(1) + }) + + it('counts a lookup that is still running against the branch cap', async () => { + const swallow = (promise: Promise): void => { + void promise.catch(() => {}) + } + const filler = stuckLookup() + const wedged = stuckLookup() + /** + * Drops the branch's in-flight record without expiring it, so it runs on + * untracked. Reuses one set of filler branches per round: fresh keys every + * round would spend the unsettled-map bound instead of the in-flight cap. + */ + const evictInflightRecords = (): void => { + for (let index = 0; index < MAX_INFLIGHT_LOOKUPS; index += 1) { + swallow( + withHostedReviewBranchCache( + { ...identity, branch: `filler/${index}` }, + { headOid: null }, + filler.lookup + ) + ) + } + } + + for (let attempt = 0; attempt < MAX_UNSETTLED_LOOKUPS_PER_KEY; attempt += 1) { + swallow(withHostedReviewBranchCache(identity, { headOid: null }, wedged.lookup)) + evictInflightRecords() + } + expect(wedged.lookup).toHaveBeenCalledTimes(MAX_UNSETTLED_LOOKUPS_PER_KEY) + + // Neither has reached its deadline, so nothing is counted as detached yet — + // but both are running with nothing able to stop them, and a third would + // strand another provider call the same way. + await expect( + withHostedReviewBranchCache(identity, { headOid: null }, wedged.lookup) + ).rejects.toThrow(/never answered/) + expect(wedged.lookup).toHaveBeenCalledTimes(MAX_UNSETTLED_LOOKUPS_PER_KEY) + }) + + it('stops admitting new branches once the unsettled map is full', async () => { + const swallow = (promise: Promise): void => { + void promise.catch(() => {}) + } + const filler = stuckLookup() + for (let index = 0; index < MAX_UNSETTLED_LOOKUP_KEYS; index += 1) { + swallow( + withHostedReviewBranchCache( + { ...identity, branch: `filler/${index}` }, + { headOid: null }, + filler.lookup + ) + ) + } + expect(filler.lookup).toHaveBeenCalledTimes(MAX_UNSETTLED_LOOKUP_KEYS) + + // Nothing has reached its deadline, so only the map bound can hold this + // back — without it the wave keeps widening until the detached cap does. + // This branch never started a lookup, so it must not be told one of its own + // is still out there. + const fresh = vi.fn(async () => openReview) + const refusal = await withHostedReviewBranchCache( + { ...identity, branch: 'fresh' }, + { headOid: null }, + fresh + ).catch((error: unknown) => (error as Error).message) + expect(refusal).toMatch(/Too many hosted review lookups are already in progress/) + expect(refusal).not.toMatch(/never answered/) + expect(fresh).not.toHaveBeenCalled() + + // A branch already counted keeps its second attempt: the bound is on new + // keys, not on the retry that proves a host recovered. + const retry = stuckLookup() + swallow( + withHostedReviewBranchCache( + { ...identity, branch: 'filler/0' }, + { headOid: null }, + retry.lookup + ) + ) + expect(retry.lookup).toHaveBeenCalledTimes(1) + }) + + it('names the process-wide cap when abandoned lookups have filled it', async () => { + const wedged = stuckLookup() + for (let index = 0; index < MAX_DETACHED_LOOKUPS; index += 1) { + void withHostedReviewBranchCache( + { ...identity, branch: `wedged/${index}` }, + { headOid: null }, + wedged.lookup + ).catch(() => {}) + } + await vi.advanceTimersByTimeAsync(HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + + // The host wedged every branch on it, not this one in particular. + const fresh = vi.fn(async () => openReview) + const refusal = await withHostedReviewBranchCache( + { ...identity, branch: 'fresh' }, + { headOid: null }, + fresh + ).catch((error: unknown) => (error as Error).message) + expect(refusal).toMatch(/abandoned without answering/) + expect(fresh).not.toHaveBeenCalled() + }) + + it('does not adopt a straggler whose invalidated scope was evicted', async () => { + const stale = stuckLookup() + const inflight = withHostedReviewBranchCache(identity, { headOid: null }, stale.lookup) + + invalidateHostedReviewBranchCache('/repo', null) + // Fill the generation map so the repo's own generation is evicted: read back + // as zero it would match what this lookup captured before the invalidation. + for (let index = 0; index < MAX_BRANCH_MAP_ENTRIES; index += 1) { + invalidateHostedReviewBranchCache(`/filler/${index}`, null) + } + + stale.resolve(null) + await expect(inflight).resolves.toBeNull() + + // Stored, that "no review" would hide the review Orca had just opened for + // the whole no-review interval. + const next = vi.fn(async () => openReview) + await expect(withHostedReviewBranchCache(identity, { headOid: null }, next)).resolves.toEqual( + openReview + ) + expect(next).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts new file mode 100644 index 00000000000..6d0338a1a5c --- /dev/null +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -0,0 +1,428 @@ +import type { HostedReviewInfo } from '../../shared/hosted-review' +import { + __resetHostedReviewActiveClaimsForTests, + isActiveBranch, + noteActiveClaim +} from './hosted-review-active-branch-claims' +import { + __resetUnsettledHostedReviewLookupsForTests, + lookupCapacityRefusal, + noteDetachedLookup, + noteLookupStarted, + settleDetachedLookup, + settleLookup +} from './hosted-review-unsettled-lookups' +import { + __resetHostedReviewScopeGenerationsForTests, + bumpScopeGeneration, + scopeGeneration +} from './hosted-review-scope-generations' +import { + __resetHostedReviewLookupBackoffForTests, + backoffUntil, + clearFailures, + dropFailuresWithPrefix, + noteFailure +} from './hosted-review-lookup-backoff' +import { + ACTIVE_REFRESH_INTERVAL_MS, + HOSTED_REVIEW_LOOKUP_DEADLINE_MS, + MAX_BRANCH_MAP_ENTRIES, + MAX_INFLIGHT_LOOKUPS, + NO_REVIEW_REFRESH_INTERVAL_MS +} from './hosted-review-refresh-pacing' + +/** + * Process-wide cache for branch review lookups (#11532). + * + * `hostedReview:forBranch` is polled by every desktop window, the mobile client + * and `orca serve` alike, and each one used to reach the provider directly. The + * host's API quota is per user, so the only place that can pace them together is + * here — the single funnel they all pass through. + * + * Pacing is tiered by what the user is looking at rather than applied flat: the + * selected worktree is O(1) and can afford a per-minute re-check, while the + * worktree list is O(N) and is what exhausts the budget. + */ + +// Why: a found review still refreshes at the callers' poll cadence; the cache +// exists to collapse concurrent clients, not to make review state go stale. +const FOUND_REVIEW_TTL_MS = 60_000 +const MAX_ENTRIES = MAX_BRANCH_MAP_ENTRIES + +type CacheEntry = { + review: HostedReviewInfo | null + fetchedAt: number + headOid: string | null + /** When the lookup that produced this answer began, for straggler ordering. */ + startedAt: number +} + +type InflightRecord = { + /** Identity, so a detached lookup can only ever clear its own entry. */ + token: object + startedAt: number + promise: Promise + /** Releases the callers and unpins the branch; idempotent. */ + expire: () => void +} + +const entries = new Map() +const inflight = new Map() +// Why: NUL is the one byte a repo path or branch name cannot contain, so a +// scope prefix cannot straddle a component boundary — invalidating `/a/b` must +// not also flush the unrelated repo at `/a/b c`. +const KEY_SEPARATOR = '\0' + +export type HostedReviewBranchCacheIdentity = { + repoPath: string + connectionId?: string | null + branch: string + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null + localGitExecOptions?: unknown +} + +export type HostedReviewBranchCacheOptions = { + /** The worktree's checked-out HEAD oid, for merged-at-head visibility. */ + headOid: string | null + /** Set by surfaces that only ever render the selected worktree. */ + active?: boolean +} + +/** Repo-scoped prefix so a single repo's entries can be dropped without a full flush. */ +function repoScope(repoPath: string, connectionId?: string | null): string { + return `${connectionId ?? ''}${KEY_SEPARATOR}${repoPath}` +} + +export function hostedReviewBranchCacheKey(identity: HostedReviewBranchCacheIdentity): string { + return [ + repoScope(identity.repoPath, identity.connectionId), + identity.branch, + // Each linked id selects a different lookup, so it belongs in the identity. + identity.linkedGitHubPR ?? '', + identity.fallbackGitHubPR ?? '', + identity.linkedGitLabMR ?? '', + identity.linkedBitbucketPR ?? '', + identity.linkedAzureDevOpsPR ?? '', + identity.linkedGiteaPR ?? '', + identity.localGitExecOptions ? JSON.stringify(identity.localGitExecOptions) : '' + ].join(KEY_SEPARATOR) +} + +// Why: a merged review is the one answer that depends on the inspected head — +// the merged-at-head carve-out keeps it visible only while the head matches. +// Negative answers are deliberately head-insensitive, so a branch under active +// commits cannot defeat the long no-review interval. +function isHeadSensitive(entry: CacheEntry): boolean { + return entry.review?.state === 'merged' +} + +function refreshIntervalMs(entry: CacheEntry, active: boolean): number { + if (entry.review !== null) { + return FOUND_REVIEW_TTL_MS + } + return active ? ACTIVE_REFRESH_INTERVAL_MS : NO_REVIEW_REFRESH_INTERVAL_MS +} + +function isFresh(entry: CacheEntry, headOid: string | null, active: boolean): boolean { + if (isHeadSensitive(entry) && headOid !== null && entry.headOid !== null) { + if (headOid !== entry.headOid) { + return false + } + } + return Date.now() - entry.fetchedAt < refreshIntervalMs(entry, active) +} + +function storeEntry(key: string, entry: CacheEntry): void { + entries.delete(key) + entries.set(key, entry) + while (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next().value + if (oldest === undefined) { + break + } + entries.delete(oldest) + } +} + +/** Clears the key's in-flight record only if it is still this lookup's. */ +function releaseInflight(key: string, token: object): boolean { + if (inflight.get(key)?.token !== token) { + return false + } + inflight.delete(key) + return true +} + +/** + * Expires records that outlived the deadline without their timer firing. Main's + * timers are suspended across a system sleep, so wall-clock age — not + * `setTimeout` alone — is what actually bounds how long a branch stays pinned. + * + * The guarantee covers tracked records only: one the size cap evicted is no + * longer reachable here and falls back to its own suspended timer. + */ +function expireOverdueInflight(now: number): void { + let overdue: InflightRecord[] | undefined + for (const record of inflight.values()) { + if (now - record.startedAt >= HOSTED_REVIEW_LOOKUP_DEADLINE_MS) { + overdue ??= [] + overdue.push(record) + } + } + // Expire after the walk: each one deletes its own entry from the map. + for (const record of overdue ?? []) { + record.expire() + } +} + +function trackInflight(key: string, record: InflightRecord): void { + inflight.set(key, record) + while (inflight.size > MAX_INFLIGHT_LOOKUPS) { + const oldest = inflight.keys().next().value + if (oldest === undefined) { + break + } + // Why: drop the record without expiring it — its own deadline still + // releases its callers, and evicting is about memory, not about failing. + // It does forfeit the sweep's wall-clock release, so the cap must stay far + // above realistic concurrency: below it, sleep-suspended timers are all an + // evicted record's callers have left. + inflight.delete(oldest) + } +} + +/** + * Drops every cached answer for a repo. Called when Orca itself opens a review, + * so the new one is visible immediately instead of after the no-review interval. + */ +export function invalidateHostedReviewBranchCache( + repoPath: string, + connectionId?: string | null +): void { + const scope = repoScope(repoPath, connectionId) + bumpScopeGeneration(scope) + const prefix = `${scope}${KEY_SEPARATOR}` + for (const key of entries.keys()) { + if (key.startsWith(prefix)) { + entries.delete(key) + } + } + dropFailuresWithPrefix(prefix) +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewBranchCacheForTests(): void { + entries.clear() + inflight.clear() + __resetHostedReviewLookupBackoffForTests() + __resetHostedReviewActiveClaimsForTests() + __resetUnsettledHostedReviewLookupsForTests() + __resetHostedReviewScopeGenerationsForTests() +} + +/** + * Whether a lookup that no longer owns the key may still install its answer. + * Losing the record — to the deadline or to the size cap — costs it the branch, + * so it may only fill a gap or replace an answer a strictly older attempt stored + * after this one began. It must never overwrite the pre-refresh answer its own + * callers were served, and never beat a replacement to the key: a late `null` + * landing there reads as a fresh "no review" and short-circuits the lookup that + * was about to give the real one. + */ +function canAdoptDetachedAnswer(key: string, startedAt: number): boolean { + if (inflight.has(key)) { + return false + } + const current = entries.get(key) + return current === undefined || (current.startedAt < startedAt && current.fetchedAt >= startedAt) +} + +/** Why the branch must not be asked again yet, or null when a lookup may start. */ +function lookupUnavailableReason(key: string): string | null { + const until = backoffUntil(key) + if (until !== null) { + return `Hosted review lookup is backing off after repeated failures. Retrying after ${new Date( + until + ).toLocaleTimeString()}.` + } + return lookupCapacityRefusal(key) +} + +/** + * Runs one lookup under a deadline. Nothing below this can be cancelled, so the + * deadline *detaches* instead: the branch is unpinned and the callers hear a + * failure, while the lookup keeps running and its answer is still adopted if it + * ever lands. That is what lets a wedged host recover in-session (P1-D). + */ +function startLookup( + key: string, + scope: string, + headOid: string | null, + lookup: () => Promise +): Promise { + const startedAt = Date.now() + const generation = scopeGeneration(scope) + const token = {} + /** The deadline released the callers; the lookup itself runs on, detached. */ + let timedOut = false + let completed = false + let release: (review: HostedReviewInfo | null) => void = () => {} + let fail: (error: unknown) => void = () => {} + const promise = new Promise((resolve, reject) => { + release = resolve + fail = reject + }) + let timer: ReturnType | undefined + + const expire = (): void => { + if (timedOut || completed) { + return + } + timedOut = true + if (timer !== undefined) { + clearTimeout(timer) + } + // Why: only the lookup still serving this key shapes the backoff. A record + // already replaced — or dropped by the size cap — has a live successor, and + // penalising the branch would slow down the retry that is already running. + if (releaseInflight(key, token)) { + noteFailure(key) + } + // Why: the lookup runs on with nothing able to stop it, so it is counted + // process-wide until it settles — that count is what stops a wedged host + // from stranding a lookup on every branch it serves. + noteDetachedLookup() + const stale = entries.get(key) + if (stale) { + release(stale.review) + return + } + fail( + new Error( + `Hosted review lookup timed out after ${Math.round( + HOSTED_REVIEW_LOOKUP_DEADLINE_MS / 1000 + )}s. It will be retried on a later poll.` + ) + ) + } + + // Why: the branch's slot is taken from the moment the lookup starts. Counting + // only from the deadline would let a first failure wave admit one lookup per + // branch with the cap still reading zero, and every one of them can wedge. + noteLookupStarted(key) + timer = setTimeout(expire, HOSTED_REVIEW_LOOKUP_DEADLINE_MS) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + // Why: track before running, so a lookup that throws synchronously clears the + // record it would otherwise leave behind for a full deadline. + trackInflight(key, { token, startedAt, promise, expire }) + + void (async () => { + try { + const review = await lookup() + // Why: a review created while this lookup was out makes its answer older + // than the invalidation; storing it would re-pin the stale "no review". + // Owning the key is what licenses a store outright — anything else is a + // straggler and has to prove it still outranks what is there. + const stored = + generation === scopeGeneration(scope) && + (inflight.get(key)?.token === token || canAdoptDetachedAnswer(key, startedAt)) + if (stored) { + storeEntry(key, { review, fetchedAt: Date.now(), headOid, startedAt }) + } + // Why: only an answer that beat the deadline proves the provider is + // healthy. Clearing on a straggler leaves a chronically wedged host at the + // base window forever, re-asking as fast as the deadline expires. + if (stored && !timedOut) { + clearFailures(key) + } + release(review) + } catch (error) { + // Why: the deadline already counted this lookup as a failure and released + // its callers; counting the late rejection again would double-escalate. + if (timedOut) { + return + } + // Why: a record the size cap dropped has a live successor, and backing the + // branch off would slow the retry that is already running. + if (inflight.get(key)?.token === token) { + noteFailure(key) + } + // Why: the last good review beats an error card here just as it does on + // the backed-off path — otherwise it blinks out on the first failure. + // An invalidation drops the entry, so this cannot revive a retired answer. + const stale = entries.get(key) + if (stale) { + release(stale.review) + return + } + fail(error) + } finally { + completed = true + if (timer !== undefined) { + clearTimeout(timer) + } + if (timedOut) { + settleDetachedLookup() + } + settleLookup(key) + releaseInflight(key, token) + } + })() + + return promise +} + +/** + * Serves `lookup` through the shared cache: a fresh answer is reused, concurrent + * callers share one in-flight lookup, and a failing branch backs off instead of + * being re-asked at every caller's poll cadence. + */ +export async function withHostedReviewBranchCache( + identity: HostedReviewBranchCacheIdentity, + options: HostedReviewBranchCacheOptions, + lookup: () => Promise +): Promise { + const key = hostedReviewBranchCacheKey(identity) + const headOid = options.headOid + // Why: sweep first, so a lookup whose deadline never fired cannot be joined — + // that is the pin this cache used to hold until the process restarted. + expireOverdueInflight(Date.now()) + if (options.active === true && noteActiveClaim(key) && entries.get(key)?.review === null) { + // Why: switching to a worktree is the user asking whether a review exists + // yet, so the long no-review interval must not answer on their behalf. This + // is the cheap half of the fast tier — it costs one lookup per selection + // rather than one per minute. + entries.delete(key) + } + const active = isActiveBranch(key) + + const cached = entries.get(key) + if (cached && isFresh(cached, headOid, active)) { + return cached.review + } + + const pending = inflight.get(key) + if (pending) { + return pending.promise + } + + const unavailable = lookupUnavailableReason(key) + if (unavailable !== null) { + // Why: a stale answer beats an error card, but with nothing cached the + // caller must hear the failure rather than read it as "no review". + if (cached) { + return cached.review + } + throw new Error(unavailable) + } + + return startLookup(key, repoScope(identity.repoPath, identity.connectionId), headOid, lookup) +} diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts index 2bb808857ef..e0bb2e49161 100644 --- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -61,6 +61,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) +import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils' import { diagnoseAuth } from '../gitlab/client' import { getHostedReviewCreationEligibility } from './hosted-review-creation' @@ -140,8 +141,11 @@ describe('GitLab self-hosted hosted review creation eligibility', () => { head: 'feature/self-hosted-mr' }) + // The probe is bounded: a dead mount must not pin the forge detection that + // every hosted-review lookup starts with (P1-D). expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { - cwd: '/repo' + cwd: '/repo', + timeout: REMOTE_URL_PROBE_TIMEOUT_MS }) expect(glabExecFileAsyncMock).toHaveBeenCalledWith( ['auth', 'status', '--hostname', 'gitlab.internal'], diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 43de5f42518..83df9010d65 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -403,7 +403,10 @@ describe('createHostedReview', () => { expect.objectContaining({ repoPath: '/repo', branch: 'feature', - localGitExecOptions: { wslDistro: 'Ubuntu' } + localGitExecOptions: { wslDistro: 'Ubuntu' }, + // Why: a stale no-review answer here would leave Create enabled after a + // review was opened outside Orca, so eligibility takes the fast tier. + active: true }) ) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 75e3189f447..f28c319f3ea 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -36,6 +36,7 @@ import { } from '../gitlab/gl-utils' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider' +import { invalidateHostedReviewBranchCache } from './hosted-review-branch-cache' import { getHostedReviewForBranch } from './hosted-review' import { getHostedReviewLocalGitOptions, @@ -503,6 +504,10 @@ export async function getHostedReviewCreationEligibility( linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, connectionId: args.connectionId ?? null, + // Why: eligibility is only ever asked for the worktree the user is acting + // on, so it earns the fast tier. Without it a review opened outside Orca + // in the last no-review interval would leave Create enabled (#11532). + active: true, ...hostedReviewExecutionContext(args) }) } catch (error) { @@ -624,7 +629,14 @@ export async function createHostedReview( return blocked } const localGitOptions = getHostedReviewLocalGitOptions(options) - return Object.keys(localGitOptions).length > 0 - ? provider.createReview(repoPath, input, connectionId, options) - : provider.createReview(repoPath, input, connectionId) + const result = + Object.keys(localGitOptions).length > 0 + ? await provider.createReview(repoPath, input, connectionId, options) + : await provider.createReview(repoPath, input, connectionId) + if (result.ok) { + // Why (#11532): the branch cache holds a "no review" answer for far longer + // than a poll interval, so Orca's own creation must retire it at once. + invalidateHostedReviewBranchCache(repoPath, connectionId) + } + return result } diff --git a/src/main/source-control/hosted-review-lookup-backoff.ts b/src/main/source-control/hosted-review-lookup-backoff.ts new file mode 100644 index 00000000000..d02b730f66d --- /dev/null +++ b/src/main/source-control/hosted-review-lookup-backoff.ts @@ -0,0 +1,64 @@ +import { + lookupBackoffDelayMs, + LOOKUP_BACKOFF_MAX_MS, + MAX_BRANCH_MAP_ENTRIES +} from './hosted-review-refresh-pacing' + +/** + * Per-branch failure escalation for hosted-review lookups (#11532). + * + * Split out of the branch cache so the cache file stays about caching: this map + * has its own lifetime — a branch stays backed off after its cached answer is + * invalidated, and it is what a lookup deadline records against (P1-D). + */ + +/** Bounded like the answer cache, so a churn of dead branches cannot grow it. */ +const MAX_BACKOFF_ENTRIES = MAX_BRANCH_MAP_ENTRIES + +const failureBackoff = new Map() + +export function backoffUntil(key: string): number | null { + // Why: a lapsed window keeps its failure count, otherwise the very act of + // retrying resets the escalation and the backoff never grows past the base. + const entry = failureBackoff.get(key) + return entry !== undefined && entry.until > Date.now() ? entry.until : null +} + +export function noteFailure(key: string): void { + const now = Date.now() + for (const [candidate, entry] of failureBackoff) { + // Why: only counts that lapsed a full max window ago are stale enough to + // forget; anything more eager would undo the escalation above. + if (now - entry.until > LOOKUP_BACKOFF_MAX_MS) { + failureBackoff.delete(candidate) + } + } + const failures = (failureBackoff.get(key)?.failures ?? 0) + 1 + failureBackoff.delete(key) + failureBackoff.set(key, { until: now + lookupBackoffDelayMs(failures), failures }) + while (failureBackoff.size > MAX_BACKOFF_ENTRIES) { + const oldest = failureBackoff.keys().next().value + if (oldest === undefined) { + break + } + failureBackoff.delete(oldest) + } +} + +/** A lookup answered, so the branch starts its next escalation from the base. */ +export function clearFailures(key: string): void { + failureBackoff.delete(key) +} + +export function dropFailuresWithPrefix(prefix: string): void { + for (const key of failureBackoff.keys()) { + if (key.startsWith(prefix)) { + failureBackoff.delete(key) + } + } +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewLookupBackoffForTests(): void { + failureBackoff.clear() +} diff --git a/src/main/source-control/hosted-review-refresh-pacing.ts b/src/main/source-control/hosted-review-refresh-pacing.ts new file mode 100644 index 00000000000..991478f29ab --- /dev/null +++ b/src/main/source-control/hosted-review-refresh-pacing.ts @@ -0,0 +1,84 @@ +/** + * Shared pacing policy for hosted-review lookups (#11532). + * + * The PR refresh coordinator's queue and the `hostedReview:forBranch` entry + * point spend the same per-user API quota, so they must agree on how long an + * answer stays good and how hard to back off after a failure. Keeping the + * numbers here stops the two paths from drifting apart. + */ + +/** A branch with no review only gains one when a review is opened, which is not a per-minute event. */ +export const NO_REVIEW_REFRESH_INTERVAL_MS = 15 * 60_000 + +/** + * The worktree the user has selected is re-checked at the old cadence: a review + * opened in a browser should show up while they are still looking at the tab. + */ +export const ACTIVE_REFRESH_INTERVAL_MS = 60_000 + +/** + * Why: the fast tier is only affordable because it is O(1) — one selected + * worktree per client. Capping it here means a caller that wrongly marks a whole + * list active costs stale cards, not the 5,000/hr budget. + */ +export const MAX_ACTIVE_BRANCHES = 8 + +/** A selection nobody has re-asserted for a whole no-review interval is not current. */ +export const ACTIVE_CLAIM_TTL_MS = NO_REVIEW_REFRESH_INTERVAL_MS + +export const LOOKUP_BACKOFF_BASE_MS = 60_000 +export const LOOKUP_BACKOFF_MAX_MS = 15 * 60_000 + +/** + * Hard deadline for a single lookup. The steps underneath are individually + * bounded (30s `gh` exec, HTTP timeouts) but they chain, so this is a hang + * detector rather than a latency budget: a lookup that outlives it is wedged on + * something that has no timeout of its own, not merely slow. + */ +export const HOSTED_REVIEW_LOOKUP_DEADLINE_MS = 2 * 60_000 + +/** + * Single bound for every branch-keyed map in this subsystem — cached answers, + * scope generations and failure backoff. One knob, so tuning one map cannot + * silently leave the others behind. + */ +export const MAX_BRANCH_MAP_ENTRIES = 500 + +/** + * Why: a memory backstop, not a concurrency limit. Evicting a live lookup costs + * a duplicate provider call — the quota problem this cache exists to prevent — + * so the cap must stay far above the branch count any client polls at once. + */ +export const MAX_INFLIGHT_LOOKUPS = MAX_BRANCH_MAP_ENTRIES + +/** + * A lookup cannot be cancelled, so one that never settles is stranded for the + * life of the process. Two per branch — counted from the moment a lookup starts, + * not from the deadline — leaves room for the retry that proves a host + * recovered; past that the branch is wedged, not slow, and asking again only + * strands another provider call. + */ +export const MAX_UNSETTLED_LOOKUPS_PER_KEY = 2 + +/** + * How many branches may hold unsettled lookups at once. Deliberately above the + * in-flight cap: that one evicts, which costs memory but never an answer, so it + * has to be what fires at realistic fan-out. Refusing a branch outright is for a + * wave no plausible worktree list explains, and only bounds the map holding it. + */ +export const MAX_UNSETTLED_LOOKUP_KEYS = 2 * MAX_BRANCH_MAP_ENTRIES + +/** Process-wide backstop for the same leak when many branches wedge at once. */ +export const MAX_DETACHED_LOOKUPS = 64 + +// Why: capped so a long-lived failure settles at LOOKUP_BACKOFF_MAX_MS rather +// than overflowing the exponent. +const MAX_BACKOFF_DOUBLINGS = 4 + +/** Exponential backoff delay for the nth consecutive lookup failure (1-based). */ +export function lookupBackoffDelayMs(failures: number): number { + return Math.min( + LOOKUP_BACKOFF_MAX_MS, + LOOKUP_BACKOFF_BASE_MS * 2 ** Math.min(Math.max(failures, 1) - 1, MAX_BACKOFF_DOUBLINGS) + ) +} diff --git a/src/main/source-control/hosted-review-scope-generations.ts b/src/main/source-control/hosted-review-scope-generations.ts new file mode 100644 index 00000000000..f96ed654814 --- /dev/null +++ b/src/main/source-control/hosted-review-scope-generations.ts @@ -0,0 +1,43 @@ +import { MAX_BRANCH_MAP_ENTRIES } from './hosted-review-refresh-pacing' + +/** + * Per-repo invalidation counter for hosted-review lookups (#11532). + * + * A lookup that was already out when Orca opened a review has an answer older + * than the invalidation, so it must not store. Comparing the generation it + * started at against the current one is what tells the two apart. + */ +const scopeGenerations = new Map() + +/** + * Floor for a scope the size cap dropped. Without it an eviction reads back as + * generation 0 — the same value a lookup that started before the first + * invalidation captured — and that lookup's pre-invalidation answer would be + * stored as current. Never decreasing is what keeps the comparison one-way: + * eviction can only make a lookup discard its result, never adopt a stale one. + */ +let evictedGeneration = 0 + +export function scopeGeneration(scope: string): number { + return scopeGenerations.get(scope) ?? evictedGeneration +} + +export function bumpScopeGeneration(scope: string): void { + const next = scopeGeneration(scope) + 1 + scopeGenerations.delete(scope) + scopeGenerations.set(scope, next) + while (scopeGenerations.size > MAX_BRANCH_MAP_ENTRIES) { + const oldest = scopeGenerations.keys().next().value + if (oldest === undefined) { + break + } + evictedGeneration = Math.max(evictedGeneration, scopeGenerations.get(oldest) ?? 0) + scopeGenerations.delete(oldest) + } +} + +/** @internal - exposed for tests only */ +export function __resetHostedReviewScopeGenerationsForTests(): void { + scopeGenerations.clear() + evictedGeneration = 0 +} diff --git a/src/main/source-control/hosted-review-unsettled-lookups.ts b/src/main/source-control/hosted-review-unsettled-lookups.ts new file mode 100644 index 00000000000..fd8f1aabdda --- /dev/null +++ b/src/main/source-control/hosted-review-unsettled-lookups.ts @@ -0,0 +1,83 @@ +import { + MAX_DETACHED_LOOKUPS, + MAX_UNSETTLED_LOOKUP_KEYS, + MAX_UNSETTLED_LOOKUPS_PER_KEY +} from './hosted-review-refresh-pacing' + +/** + * Accounting for lookups that have not settled (P1-D). + * + * Nothing under the hosted-review funnel can be cancelled, so a lookup runs + * until it settles — or forever, on a wedged host. Counting them is what stops + * every deadline-plus-backoff cycle from stranding another one: past the cap the + * branch answers unavailable instead of spawning a duplicate zombie. + * + * A branch reserves its slot when the lookup *starts*, not when the deadline + * detaches it: an in-flight lookup the size cap dropped from the in-flight map is + * just as unstoppable as a detached one, and admitting another for the same + * branch alongside it would strand both. + */ +const unsettledByKey = new Map() + +/** + * Detached work is counted process-wide too, because a wedged host wedges every + * branch on it. In-flight lookups are deliberately left out of this total: they + * are the ordinary fan-out of a client polling its worktree list, and capping + * those at 64 would fail legitimate lookups on any machine with more branches. + */ +let detachedTotal = 0 + +/** Reserves the branch's slot for a lookup that is about to start. */ +export function noteLookupStarted(key: string): void { + unsettledByKey.set(key, (unsettledByKey.get(key) ?? 0) + 1) +} + +/** The deadline gave up on this lookup; it runs on, and stays counted. */ +export function noteDetachedLookup(): void { + detachedTotal += 1 +} + +/** Called when a lookup finally settles, freeing its branch slot. */ +export function settleLookup(key: string): void { + const remaining = (unsettledByKey.get(key) ?? 0) - 1 + if (remaining > 0) { + unsettledByKey.set(key, remaining) + } else { + unsettledByKey.delete(key) + } +} + +/** Called when a detached lookup settles, freeing its process-wide slot. */ +export function settleDetachedLookup(): void { + detachedTotal = Math.max(0, detachedTotal - 1) +} + +/** + * Why this branch cannot start a lookup, or null when it can. Only the per-branch + * cap may blame an attempt of its own: the other two hold back branches that have + * never asked for anything, and telling those users to wait on a lookup they + * never started sends them looking for a stall that is not theirs. + */ +export function lookupCapacityRefusal(key: string): string | null { + if ((unsettledByKey.get(key) ?? 0) >= MAX_UNSETTLED_LOOKUPS_PER_KEY) { + return 'Hosted review lookup is still running from an earlier attempt that never answered. It will be retried once that attempt settles.' + } + // Why: this map is bounded by refusing new branches, not by evicting old ones — + // its entries are lookups still out there, and dropping a key would re-admit + // one for a branch already wedged. The bound sits above the in-flight cap so a + // wave wide enough to reach it is pathological, not the ordinary fan-out of a + // client polling a long worktree list. + if (!unsettledByKey.has(key) && unsettledByKey.size >= MAX_UNSETTLED_LOOKUP_KEYS) { + return 'Too many hosted review lookups are already in progress to start another. This branch will be retried once some of them settle.' + } + if (detachedTotal >= MAX_DETACHED_LOOKUPS) { + return 'Too many hosted review lookups have been abandoned without answering. This branch will be retried once the host catches up.' + } + return null +} + +/** @internal - exposed for tests only */ +export function __resetUnsettledHostedReviewLookupsForTests(): void { + unsettledByKey.clear() + detachedTotal = 0 +} diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 102ebfebdbb..024ecff7168 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getHostedReviewForBranch } from './hosted-review' +import { __resetHostedReviewBranchCacheForTests } from './hosted-review-branch-cache' const { getProjectSlugMock, @@ -36,6 +38,7 @@ vi.mock('../gitlab/client', () => ({ vi.mock('../github/client', () => ({ getRepoSlug: getRepoSlugMock, getPRForBranchOutcome: getPRForBranchOutcomeMock, + getGitHubPRLookupRateLimitBlock: vi.fn(async () => null), createGitHubPullRequest: vi.fn() })) @@ -66,8 +69,6 @@ vi.mock('../gitea/client', () => ({ getGiteaPullRequest: vi.fn() })) -import { getHostedReviewForBranch } from './hosted-review' - describe('getHostedReviewForBranch', () => { beforeEach(() => { getProjectSlugMock.mockReset() @@ -80,6 +81,9 @@ describe('getHostedReviewForBranch', () => { getAzureDevOpsPullRequestForBranchMock.mockReset() getGiteaRepoSlugMock.mockReset() getGiteaPullRequestForBranchMock.mockReset() + // The branch cache is process-wide, so one test's answer would otherwise + // satisfy the next one's lookup. + __resetHostedReviewBranchCacheForTests() }) it('maps GitLab merge requests into the hosted review surface', async () => { diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index fbc5a71a528..c6fd3bd8fb8 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -1,6 +1,11 @@ import type { HostedReviewInfo } from '../../shared/hosted-review' +import { assertRemoteUrlReadable } from '../git/remote-url-probe' import { getForgeProviderForRepository, type ForgeProviderId } from './forge-provider' -import type { HostedReviewExecutionOptions } from './hosted-review-git-options' +import { withHostedReviewBranchCache } from './hosted-review-branch-cache' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from './hosted-review-git-options' function reviewLinkForProvider( input: Parameters[0], @@ -35,6 +40,11 @@ export async function getHostedReviewForBranch( linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null currentHeadOid?: string | null + /** + * Set by surfaces that only ever render the selected worktree, which is the + * one branch cheap enough to re-check per minute (#11532). + */ + active?: boolean } & HostedReviewExecutionOptions ): Promise { const branchName = input.branch.replace(/^refs\/heads\//, '') @@ -52,20 +62,38 @@ export async function getHostedReviewForBranch( return null } - const provider = await getForgeProviderForRepository({ - repoPath: input.repoPath, - connectionId: input.connectionId, - ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}) - }) - if (!provider) { - return null - } - return provider.getReviewForBranch({ - repoPath: input.repoPath, - connectionId: input.connectionId, - branch: branchName, - ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}), - githubCurrentHeadOid: input.currentHeadOid ?? null, - ...reviewLinkForProvider(input, provider.id) - }) + const headOid = input.currentHeadOid?.trim() || null + // Why (#11532): every client polls this one entry point, and they share the + // host's per-user API quota, so the cache has to sit above the provider call. + return withHostedReviewBranchCache( + { ...input, branch: branchName }, + { headOid, ...(input.active === true ? { active: true } : {}) }, + async () => { + const provider = await getForgeProviderForRepository({ + repoPath: input.repoPath, + connectionId: input.connectionId, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}) + }) + if (!provider) { + // Why: forge detection swallows probe failures, so a remote read that + // was killed on its deadline (or lost its relay) would otherwise be + // cached as a definitive "no review" for the whole no-review interval. + // Throwing keeps it on the cache's failure path instead (P1-D). + await assertRemoteUrlReadable({ + repoPath: input.repoPath, + connectionId: input.connectionId, + ...getHostedReviewLocalGitOptions(input) + }) + return null + } + return provider.getReviewForBranch({ + repoPath: input.repoPath, + connectionId: input.connectionId, + branch: branchName, + ...(input.localGitExecOptions ? { localGitExecOptions: input.localGitExecOptions } : {}), + githubCurrentHeadOid: headOid, + ...reviewLinkForProvider(input, provider.id) + }) + } + ) } diff --git a/src/main/speech/model-manager-progress-callback.test.ts b/src/main/speech/model-manager-progress-callback.test.ts index 8b566abad70..1c8633188e7 100644 --- a/src/main/speech/model-manager-progress-callback.test.ts +++ b/src/main/speech/model-manager-progress-callback.test.ts @@ -45,4 +45,30 @@ describe('ModelManager progress callbacks', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('coalesces per-chunk download progress to whole percent', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) + try { + const manager = new ModelManager(dir) + const internals = manager as unknown as ModelManagerInternals + const listener = vi.fn() + manager.setProgressCallback(listener) + + // A 500MB model over a 64KB chunk stream reports this many times. + for (let chunk = 0; chunk < 8_000; chunk += 1) { + internals.updateState('model-a', 'downloading', chunk / 8_000) + } + + expect(listener.mock.calls.map(([, progress]) => progress)).toEqual( + Array.from({ length: 101 }, (_unused, percent) => percent / 100) + ) + const afterDownload = listener.mock.calls.length + internals.updateState('model-a', 'extracting') + internals.updateState('model-a', 'ready') + internals.updateState('model-a', 'ready') + expect(listener.mock.calls.length).toBe(afterDownload + 3) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 9125ffa4962..f183805cddd 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -387,10 +387,24 @@ export class ModelManager { progress?: number, error?: string ): void { - const state: SpeechModelState = { id: modelId, status, progress, error } + const previous = this.modelStates.get(modelId) + // Whole-percent state matches the UI and prevents chunk-level IPC/poll churn. + const reportedProgress = + status === 'downloading' && progress !== undefined + ? Math.round(progress * 100) / 100 + : progress + if ( + status === 'downloading' && + previous?.status === 'downloading' && + previous.error === error && + previous.progress === reportedProgress + ) { + return + } + const state: SpeechModelState = { id: modelId, status, progress: reportedProgress, error } this.modelStates.set(modelId, state) - // Why: notify on every state change (not just progress) so extracting/ready/error transitions reach the UI. - const progressValue = progress ?? (status === 'extracting' ? 0.95 : -1) + // Repeated non-download states can be the requesting window's only resync signal. + const progressValue = reportedProgress ?? (status === 'extracting' ? 0.95 : -1) for (const callback of this.progressCallbacks) { callback(modelId, progressValue) } diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 4a60237874a..da61921ca6a 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -2,7 +2,6 @@ // 13-byte framing header matching VS Code's PersistentProtocol wire format. // See design-ssh-support.md § JSON-RPC Protocol Specification. -import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' import { FrameDecoder, FrameDecoderContinuationError, @@ -12,7 +11,7 @@ import { FRAME_DECODER_MAX_BYTES_PER_TURN, FRAME_DECODER_MAX_TURN_MS, FRAME_DECODER_MAX_RETAINED_BYTES -} from './relay-frame-decoder' +} from '../../shared/relay-frame-decoder' export { FrameDecoder, @@ -24,7 +23,7 @@ export { FRAME_DECODER_MAX_TURN_MS, FRAME_DECODER_MAX_RETAINED_BYTES } -export type { DecodedFrame, FrameDecoderOptions } from './relay-frame-decoder' +export type { DecodedFrame, FrameDecoderOptions } from '../../shared/relay-frame-decoder' export const RELAY_VERSION = '0.1.0' export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` @@ -41,9 +40,6 @@ export const MessageType = { export const KEEPALIVE_SEND_MS = 5_000 export const TIMEOUT_MS = 20_000 -/** Reconnection grace period (default, overridable by relay --grace-time). */ -export const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - // ── Relay error codes ─────────────────────────────────────────────── export const RelayErrorCode = { diff --git a/src/main/ssh/sftp-namespace-resolution.ts b/src/main/ssh/sftp-namespace-resolution.ts index 5ee329a40c4..dd019a84d88 100644 --- a/src/main/ssh/sftp-namespace-resolution.ts +++ b/src/main/ssh/sftp-namespace-resolution.ts @@ -116,8 +116,8 @@ function realpathSftp(sftp: SFTPWrapper, remotePath: string): Promise { }) } -// Why: sftpPathExists collapses "absent" and "could not tell" into one boolean; -// namespace selection must never treat an inconclusive probe as absence. +// Why: namespace selection must never treat an inconclusive lstat as absence, +// so the probe reports "unknown" rather than collapsing to a boolean. function probeMarkerPath(sftp: SFTPWrapper, remotePath: string): Promise { return new Promise((resolve) => { sftp.lstat(remotePath, (err) => { diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index e962c7463fa..6344a7c1457 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -282,25 +282,3 @@ async function assertLocalUploadPathInsideRoot( throw new Error(`Path escaped upload root: ${candidatePath}`) } } - -/** - * Check whether a path exists on the remote via SFTP lstat. - * Returns true if the path exists (file, directory, or symlink). - */ -export function sftpPathExists(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.lstat(remotePath, (err) => { - if (!err) { - resolve(true) - return - } - // Why: SFTP status code 2 = SSH_FX_NO_SUCH_FILE — the path does not - // exist, which is the expected "no collision" signal for deconfliction. - if ((err as { code?: number }).code === 2) { - resolve(false) - return - } - reject(err) - }) - }) -} diff --git a/src/main/ssh/ssh-connect-attempt-cancellation.test.ts b/src/main/ssh/ssh-connect-attempt-cancellation.test.ts new file mode 100644 index 00000000000..b98b54762ad --- /dev/null +++ b/src/main/ssh/ssh-connect-attempt-cancellation.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { + createCancelledConnectAttemptError, + isCancelledConnectAttemptError +} from './ssh-connect-attempt-cancellation' +import { isAuthError } from './ssh-connection-utils' +import { isTransientReconnectError } from './ssh-reconnect-error-classification' + +describe('createCancelledConnectAttemptError', () => { + it('is recognisable by identity, not just by message text', () => { + const error = createCancelledConnectAttemptError() + expect(isCancelledConnectAttemptError(error)).toBe(true) + // A cancelled attempt must never look like a permanent failure to the reconnect ladder. + expect(isTransientReconnectError(error)).toBe(false) + expect(isAuthError(error)).toBe(false) + }) + + it('does not classify unrelated errors as cancellations', () => { + expect(isCancelledConnectAttemptError(new Error('connect ETIMEDOUT'))).toBe(false) + }) + + it('ignores a lookalike message from a producer that skipped the factory', () => { + // Every real producer imports the factory, so message text alone must not qualify. + expect( + isCancelledConnectAttemptError(new Error(createCancelledConnectAttemptError().message)) + ).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-connect-attempt-cancellation.ts b/src/main/ssh/ssh-connect-attempt-cancellation.ts new file mode 100644 index 00000000000..0229f827ae0 --- /dev/null +++ b/src/main/ssh/ssh-connect-attempt-cancellation.ts @@ -0,0 +1,16 @@ +const CANCELLED_CONNECT_ATTEMPT_MESSAGE = 'SSH connection attempt was cancelled' +const CANCELLED_CONNECT_ATTEMPT_NAME = 'SshConnectAttemptCancelledError' + +// Why: cancellation means a newer attempt (or disconnect) already owns the connection, so it must be +// recognisable by identity — publishing its message as a permanent error hides the live attempt. +export function createCancelledConnectAttemptError(): Error { + const error = new Error(CANCELLED_CONNECT_ATTEMPT_MESSAGE) + error.name = CANCELLED_CONNECT_ATTEMPT_NAME + return error +} + +// Identity only: every producer goes through createCancelledConnectAttemptError, so a message match +// would add no reach and would misclassify an unrelated error that happens to share the wording. +export function isCancelledConnectAttemptError(err: Error): boolean { + return err.name === CANCELLED_CONNECT_ATTEMPT_NAME +} diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 5e06a9e7386..eba0de23087 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -233,9 +233,21 @@ describe('isAuthError', () => { ) }) + it.each([ + 'Permission denied (publickey).', + 'Permission denied (publickey,password).', + 'Permission denied, please try again.' + ])('detects OpenSSH credential rejection: %s', (message) => { + expect(isAuthError(new Error(message))).toBe(true) + }) + it('returns false for transient errors', () => { expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false) }) + + it('does not classify a local filesystem permission failure as authentication', () => { + expect(isAuthError(new Error('Permission denied (os error 13)'))).toBe(false) + }) }) // ── isAgentFallbackError ──────────────────────────────────────────── diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index bce298039be..26dd66f16e4 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -52,6 +52,9 @@ export function isAuthError(err: Error): boolean { msg.includes('all configured authentication methods failed') || msg.includes('authentication failed') || msg.includes('too many authentication failures') || + /permission denied(?:, please try again\.?| \([^)]*(?:publickey|password|keyboard-interactive|gssapi|hostbased)[^)]*\))/.test( + msg + ) || (err as { level?: string }).level === 'client-authentication' ) } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index a3bcadf66dc..bdb38f83466 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -12,6 +12,7 @@ let connectErrorMessage = '' let connectErrorCode = '' let destroyErrorMessage = '' let connectSequence: ('ready' | Error)[] = [] +let connectAttempts = 0 let execBehavior: 'callback' | 'pending' = 'callback' let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null let sftpBehavior: 'callback' | 'pending' = 'callback' @@ -34,6 +35,27 @@ function emitSshEvent(event: string, ...args: unknown[]): void { } } +function nextSshClientCreation(): Promise { + return new Promise((resolve) => { + notifyClientCreated = resolve + }) +} + +async function connectWithFakeTimers(conn: SshConnection): Promise { + const clientCreated = nextSshClientCreation() + const connected = conn.connect() + await clientCreated + await vi.advanceTimersByTimeAsync(1) + await connected +} + +async function advanceToNextSshClient(delayMs: number): Promise { + const clientCreated = nextSshClientCreation() + await vi.advanceTimersByTimeAsync(delayMs) + await clientCreated + await vi.advanceTimersByTimeAsync(1) +} + vi.mock('ssh2', () => { class MockBaseAgent {} class MockSshClient { @@ -62,6 +84,7 @@ vi.mock('ssh2', () => { } } connect(config?: unknown) { + connectAttempts += 1 this.lastConnectConfig = config const hostVerifier = (config as { hostVerifier?: (key: Buffer) => boolean } | undefined) ?.hostVerifier @@ -125,17 +148,23 @@ vi.mock('ssh2', () => { }) const { + findSystemSshMock, getOrcaControlSocketPathMock, removeControlSocketPathMock, spawnSystemSshCommandMock, spawnSystemSshMock } = vi.hoisted(() => ({ + findSystemSshMock: vi.fn<() => string | null>(), getOrcaControlSocketPathMock: vi.fn(), removeControlSocketPathMock: vi.fn(), spawnSystemSshMock: vi.fn(), spawnSystemSshCommandMock: vi.fn() })) +// Why: security-key transport selection scans the real ~/.ssh defaults, so a developer's own +// FIDO2 key would otherwise decide which transport these tests take. +vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock })) + vi.mock('./ssh-system-fallback', () => ({ getOrcaControlSocketPath: getOrcaControlSocketPathMock, spawnSystemSsh: spawnSystemSshMock, @@ -170,7 +199,8 @@ import { writeFileViaSystemSsh } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshTarget } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../shared/ssh-types' import { createOpenSshPrivateKeyFixture, createOpenSshPublicKeyFixture @@ -245,6 +275,15 @@ function createFailingSystemCommandChannel( return channel } +// A probe that never answers — the shape a FIDO2/system-transport host takes when the network drops. +function createHangingSystemCommandChannel(): ReturnType { + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn() + return channel +} + function createPendingSystemSshProcess() { const stdout = new EventEmitter() return { @@ -281,6 +320,7 @@ describe('SshConnection', () => { connectErrorCode = '' destroyErrorMessage = '' connectSequence = [] + connectAttempts = 0 execBehavior = 'callback' pendingExecCallback = null sftpBehavior = 'callback' @@ -306,6 +346,8 @@ describe('SshConnection', () => { vi.mocked(writeFileViaSystemSsh).mockResolvedValue(undefined) vi.mocked(resolveWithSshG).mockReset() vi.mocked(resolveWithSshG).mockResolvedValue(null) + findSystemSshMock.mockReset() + findSystemSshMock.mockReturnValue(null) vi.unstubAllEnvs() }) @@ -420,6 +462,291 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('connected') }) + it('escalates the backoff across repeated post-handshake drops', async () => { + vi.useFakeTimers() + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + expect(clientInstances).toHaveLength(1) + + emitSshEvent('close') + await vi.advanceTimersByTimeAsync(999) + expect(clientInstances).toHaveLength(1) + await advanceToNextSshClient(1) + expect(clientInstances).toHaveLength(2) + + // Why: a single published counter pinned every post-handshake drop at the 1000ms head step. + emitSshEvent('close') + await vi.advanceTimersByTimeAsync(1999) + expect(clientInstances).toHaveLength(2) + await advanceToNextSshClient(1) + expect(clientInstances).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('publishes reconnectAttempt=0 on the reconnected state and the escalating step while reconnecting', async () => { + vi.useFakeTimers() + try { + const published: { status: string; reconnectAttempt: number }[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => + published.push({ status: state.status, reconnectAttempt: state.reconnectAttempt }) + ) + }) + ) + await connectWithFakeTimers(conn) + + for (let drop = 0; drop < 3; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(30_000) + } + + expect( + published.filter((e) => e.status === 'reconnecting').map((e) => e.reconnectAttempt) + ).toEqual([0, 1, 2]) + // src/main/ipc/ssh.ts gates the relay redeploy on reconnectAttempt === 0 at 'connected'. + expect( + published.filter((e) => e.status === 'connected').map((e) => e.reconnectAttempt) + ).toEqual([0, 0, 0, 0]) + } finally { + vi.useRealTimers() + } + }) + + it('restarts the ladder at the head when an explicit reconnect fails', async () => { + // Accepted delta: reset() puts the ladder at the head and the explicit attempt consumes no + // step, so the first failure publishes 0/1000ms where the single-counter version published 1/2000ms. + vi.useFakeTimers() + try { + const published: { status: string; reconnectAttempt: number }[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => + published.push({ status: state.status, reconnectAttempt: state.reconnectAttempt }) + ) + }) + ) + await connectWithFakeTimers(conn) + + connectBehavior = 'error' + connectErrorMessage = 'connect ETIMEDOUT 10.0.0.5:22' + connectErrorCode = 'ETIMEDOUT' + published.length = 0 + const clientCreated = nextSshClientCreation() + const reconnected = conn.reconnect() + await clientCreated + await vi.advanceTimersByTimeAsync(1) + await reconnected + + // Shipped published [0, 1] here; the ladder's reset() keeps the retry at the head instead. + expect( + published.filter((e) => e.status === 'reconnecting').map((e) => e.reconnectAttempt) + ).toEqual([0, 0]) + } finally { + vi.useRealTimers() + } + }) + + it('reaches reconnection-failed after 9 consecutive handshake failures', async () => { + vi.useFakeTimers() + try { + const statuses: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => statuses.push(state.status)) + }) + ) + await connectWithFakeTimers(conn) + + connectBehavior = 'error' + connectErrorMessage = 'connect ETIMEDOUT 10.0.0.5:22' + connectErrorCode = 'ETIMEDOUT' + emitSshEvent('close') + for (const delayMs of RECONNECT_BACKOFF_MS) { + await advanceToNextSshClient(delayMs) + } + + expect(statuses).toContain('reconnection-failed') + // Pin the budget itself: the initial success plus exactly RECONNECT_BACKOFF_MS.length retries. + // Counting a failure twice, or giving up early, would strand a user on a flaky link. + expect(connectAttempts).toBe(1 + RECONNECT_BACKOFF_MS.length) + } finally { + vi.useRealTimers() + } + }) + + it('retries a saturated flap streak while the remote relay is still in grace', async () => { + vi.useFakeTimers() + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + + // Saturate the delay ladder on flaps alone; 45s reconnects each drop while staying under + // STABLE_CONNECTION_MS, so the ladder never resets to the head. + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(45_000) + } + expect(conn.getState().status).toBe('connected') + + const before = clientInstances.length + emitSshEvent('close') + // The retry plus a worst-case handshake must land inside the shortest configurable relay grace, + // or the remote daemon shuts down and takes every PTY on that host with it. + await advanceToNextSshClient( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - CONNECT_TIMEOUT_MS - 1 + ) + expect(clientInstances.length).toBeGreaterThan(before) + } finally { + vi.useRealTimers() + } + }) + + it('logs the delay ladder position separately from the failure streak', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const conn = new SshConnection(createTarget(), createCallbacks()) + await connectWithFakeTimers(conn) + + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(45_000) + } + + const lastReconnectLog = warn.mock.calls + .map((call) => String(call[0])) + .findLast((line) => line.includes('Reconnecting to')) + // A saturated flap ladder must not read like the connection is one step from giving up. + expect(lastReconnectLog).toContain('delay step 9/9') + expect(lastReconnectLog).toContain('failed handshakes 0/9') + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps retrying when a flap streak is followed by one handshake failure', async () => { + vi.useFakeTimers() + try { + const statuses: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => statuses.push(state.status)) + }) + ) + await connectWithFakeTimers(conn) + + // 12 flaps saturate the delay ladder without ever touching the failure streak. + for (let drop = 0; drop < 12; drop++) { + emitSshEvent('close') + await advanceToNextSshClient(30_000) + } + connectSequence = [new Error('connect ETIMEDOUT 10.0.0.5:22')] + emitSshEvent('close') + await advanceToNextSshClient(30_000) + await advanceToNextSshClient(30_000) + + expect(statuses).not.toContain('reconnection-failed') + expect(conn.getState().status).toBe('connected') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a system-transport target on the ladder after a probe timeout', async () => { + vi.useFakeTimers() + try { + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const statuses: string[] = [] + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => statuses.push(state.status)) + }) + ) + await conn.connect() + expect(conn.usesSystemSshTransport()).toBe(true) + + // The probe times out with OpenSSH prose, not an errno the transient code table matches. + spawnSystemSshCommandMock.mockImplementation(() => createHangingSystemCommandChannel()) + statuses.length = 0 + const reconnected = conn.reconnect() + await vi.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS) + await reconnected + + // Shipped published 'error' here, stranding FIDO2 hosts with no path back short of a restart. + expect(statuses).not.toContain('error') + expect(conn.getState().status).toBe('reconnecting') + + const probesBefore = spawnSystemSshCommandMock.mock.calls.length + await vi.advanceTimersByTimeAsync(RECONNECT_BACKOFF_MS[0]) + expect(spawnSystemSshCommandMock.mock.calls.length).toBeGreaterThan(probesBefore) + } finally { + vi.useRealTimers() + } + }) + + it('publishes no cancellation error when a connect supersedes a parked ladder attempt', async () => { + const published: { status: string; error?: string }[] = [] + let releaseParked: (() => void) | null = null + vi.mocked(resolveWithSshG).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseParked = () => resolve(null) + }) + ) + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => published.push({ status: state.status, ...state })) + }) + ) + + // The ladder attempt parks inside ssh -G while the user presses Connect. + const parked = conn.reconnect() + await conn.connect() + releaseParked!() + await parked + + expect(conn.getState().status).toBe('connected') + // Shipped published error:'SSH connection attempt was cancelled' over a live connection. + expect(published.filter((entry) => entry.status === 'error')).toEqual([]) + expect(published.map((entry) => entry.error).filter(Boolean)).toEqual([]) + }) + + it('rejects a superseded connect without publishing a permanent error', async () => { + const published: string[] = [] + let releaseParked: (() => void) | null = null + vi.mocked(resolveWithSshG).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseParked = () => resolve(null) + }) + ) + const conn = new SshConnection( + createTarget(), + createCallbacks({ + onStateChange: vi.fn((_id, state) => published.push(state.status)) + }) + ) + + const superseded = conn.connect() + await conn.connect() + releaseParked!() + + await expect(superseded).rejects.toThrow('SSH connection attempt was cancelled') + expect(conn.getState().status).toBe('connected') + expect(published).not.toContain('error') + }) + it('transitions through connecting → connected states', async () => { const states: string[] = [] const callbacks = createCallbacks({ @@ -1454,6 +1781,94 @@ describe('SshConnection', () => { expect(conn.canRunConcurrentExecCommands()).toBe(false) }) + it('retries a generic system SSH probe timeout without ControlMaster', async () => { + vi.useFakeTimers() + try { + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) + spawnSystemSshCommandMock + .mockImplementationOnce(() => createHangingSystemCommandChannel()) + .mockImplementation(() => createSystemCommandChannel()) + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + const settled = conn.connect() + await vi.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS) + + await expect(settled).resolves.toBeUndefined() + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(2) + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ disableControlMaster: true }) + ) + } finally { + vi.useRealTimers() + } + }) + + it('skips a second probe for a definite host failure', async () => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'ssh: connect to host box port 22: No route to host') + ) + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await expect(conn.connect()).rejects.toThrow('No route to host') + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + }) + + it.each([ + 'Permission denied (publickey,password).', + 'Encrypted private key detected, but no passphrase given' + ])('skips a second probe for terminal credential failures: %s', async (stderr) => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, stderr) + ) + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await expect(conn.connect()).rejects.toThrow(stderr) + expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1) + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + }) + + it('retries a generic direct system SSH timeout without ControlMaster', async () => { + vi.useFakeTimers() + try { + getOrcaControlSocketPathMock.mockImplementation( + (_target: SshTarget, options?: { disableControlMaster?: boolean }) => + options?.disableControlMaster ? null : '/tmp/orca-ssh-501/stale-socket' + ) + spawnSystemSshMock + .mockImplementationOnce(() => createPendingSystemSshProcess()) + .mockImplementation(() => createSystemSshProcess()) + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + const settled = conn.connectViaSystemSsh() + await vi.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS) + + await expect(settled).resolves.toBeDefined() + expect(spawnSystemSshMock).toHaveBeenCalledTimes(2) + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + expect(spawnSystemSshMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ disableControlMaster: true }) + ) + } finally { + vi.useRealTimers() + } + }) + it('uses system SSH transport for ProxyCommand targets before ssh2 auth', async () => { const conn = new SshConnection( createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), @@ -1473,6 +1888,7 @@ describe('SshConnection', () => { }) it('uses system SSH before ssh2 parses a security-key private key', async () => { + findSystemSshMock.mockReturnValue('/usr/bin/ssh') const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-')) const keyPath = join(directory, 'id_ed25519_sk') writeFileSync( @@ -1494,6 +1910,7 @@ describe('SshConnection', () => { }) it('uses system SSH for an agent-backed security-key public identity', async () => { + findSystemSshMock.mockReturnValue('/usr/bin/ssh') const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-')) const identityPath = join(directory, 'id_ed25519_sk') writeFileSync( @@ -1751,6 +2168,19 @@ describe('SshConnection', () => { expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() }) + it('publishes auth-failed when OpenSSH denies reconnect credentials', async () => { + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + await conn.connect() + + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'Permission denied (publickey,password).') + ) + await conn.reconnect() + + expect(conn.getState().status).toBe('auth-failed') + }) + it('clears system SSH transport when the GSSAPI-first probe throws synchronously', async () => { // Why: no system ssh binary makes spawnSystemSshCommand throw before the // probe's try/catch, so the ssh2 fall-through must still reset the flag — @@ -2041,6 +2471,22 @@ describe('SshConnection', () => { expect(conn.canRunConcurrentExecCommands()).toBe(false) }) + it.each([ + 'Permission denied (publickey,password).', + 'Encrypted private key detected, but no passphrase given' + ])('skips a direct retry for terminal credential failures: %s', async (message) => { + getOrcaControlSocketPathMock.mockReturnValue('/tmp/orca-ssh-501/stale-socket') + spawnSystemSshMock.mockImplementation(() => { + throw new Error(message) + }) + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + + await expect(conn.connectViaSystemSsh()).rejects.toThrow(message) + expect(spawnSystemSshMock).toHaveBeenCalledTimes(1) + expect(removeControlSocketPathMock).toHaveBeenCalledWith('/tmp/orca-ssh-501/stale-socket') + }) + it('kills delayed direct system SSH startup on disconnect and ignores late stdout', async () => { const proc = createPendingSystemSshProcess() spawnSystemSshMock.mockReturnValueOnce(proc) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 37d661587ad..1bb7457b8bc 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -40,6 +40,15 @@ import { type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' +import { + createCancelledConnectAttemptError, + isCancelledConnectAttemptError +} from './ssh-connect-attempt-cancellation' +import { + isDefiniteSystemSshHostFailure, + isTransientReconnectError +} from './ssh-reconnect-error-classification' +import { SshReconnectLadder } from './ssh-reconnect-ladder' import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import { requiresSystemSshForSecurityKey, @@ -126,6 +135,7 @@ export class SshConnection { private callbacks: SshConnectionCallbacks private target: SshTarget private reconnectTimer: ReturnType | null = null + private readonly reconnectLadder = new SshReconnectLadder() private disposed = false private cachedPassphrase: string | null = null private cachedPassword: string | null = null @@ -599,8 +609,11 @@ export class SshConnection { let lastError: Error | null = null for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { + const connectGeneration = ++this.connectGeneration try { - await this.attemptConnect() + await this.attemptConnect(connectGeneration) + this.reconnectLadder.reset() + this.reconnectLadder.markConnected(Date.now()) return } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) @@ -610,6 +623,14 @@ export class SshConnection { throw lastError } + // Why: a newer attempt owns the connection now and publishes its own outcome; retrying here would fight it. + if ( + !this.isCurrentConnectAttempt(connectGeneration) || + isCancelledConnectAttemptError(lastError) + ) { + throw lastError + } + if (isAuthError(lastError) || isPassphraseError(lastError)) { this.setState('auth-failed', lastError.message) throw lastError @@ -631,11 +652,11 @@ export class SshConnection { throw finalError } - private async attemptConnect(): Promise { + // Why: callers claim the generation so their catch can tell their own failure from a superseded one. + private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise { this.setState('connecting') this.proxyProcess?.kill() this.proxyProcess = null - const connectGeneration = ++this.connectGeneration const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch( () => null @@ -852,8 +873,9 @@ export class SshConnection { // Why: OS sleep/wake can leave ssh2 thinking a dead TCP socket is still connected; tear down and reconnect so the relay can reattach remote PTYs. this.closeTransportsForReconnect() this.state.reconnectAttempt = 0 + this.reconnectLadder.reset() this.setState('reconnecting') - await this.runReconnectAttempt(0) + await this.runReconnectAttempt() } private async doSystemSshProbe(connectGeneration: number): Promise { @@ -899,7 +921,7 @@ export class SshConnection { const onClose = (code: number | null): void => { settle(() => { if (this.disposed || connectGeneration !== this.connectGeneration) { - reject(new Error('SSH connection attempt was cancelled')) + reject(this.createCancelledConnectAttemptError()) return } if ( @@ -960,6 +982,13 @@ export class SshConnection { throw err } removeControlSocketPath(controlPath) + // Why: a timeout retry doubles the ladder step, but a stuck master can cause the first timeout. + if ( + isDefiniteSystemSshHostFailure(err) || + (err instanceof Error && (isAuthError(err) || isPassphraseError(err))) + ) { + throw err + } this.systemSshResolvedConfig = cloneResolvedConfig(resolved) this.systemSshControlMasterDisabledForSession = true try { @@ -985,6 +1014,12 @@ export class SshConnection { throw err } removeControlSocketPath(controlPath) + if ( + isDefiniteSystemSshHostFailure(err) || + (err instanceof Error && (isAuthError(err) || isPassphraseError(err))) + ) { + throw err + } this.systemSshControlMasterDisabledForSession = true if (!this.isCurrentConnectAttempt(connectGeneration)) { throw this.createCancelledConnectAttemptError() @@ -1082,7 +1117,7 @@ export class SshConnection { } private createCancelledConnectAttemptError(): Error { - return new Error('SSH connection attempt was cancelled') + return createCancelledConnectAttemptError() } private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel { @@ -1177,7 +1212,7 @@ export class SshConnection { cleanupStartupListeners() client.end() client.destroy() - reject(new Error('SSH connection attempt was cancelled')) + reject(this.createCancelledConnectAttemptError()) return } settled = true @@ -1239,40 +1274,55 @@ export class SshConnection { if (this.disposed || this.reconnectTimer) { return } - const attempt = this.state.reconnectAttempt - if (attempt >= RECONNECT_BACKOFF_MS.length) { + const decision = this.reconnectLadder.next(Date.now()) + if (decision.kind === 'give-up') { this.setState('reconnection-failed', 'Max reconnection attempts reached') return } + this.state.reconnectAttempt = decision.attemptIndex this.setState('reconnecting') + console.warn( + `[ssh] Reconnecting to ${this.target.label} in ${decision.delayMs}ms (delay step ${decision.attemptIndex + 1}/${RECONNECT_BACKOFF_MS.length}, failed handshakes ${this.reconnectLadder.failedAttemptStreak}/${RECONNECT_BACKOFF_MS.length})` + ) this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null if (this.disposed) { return } - await this.runReconnectAttempt(attempt) - }, RECONNECT_BACKOFF_MS[attempt]) + await this.runReconnectAttempt() + }, decision.delayMs) } - private async runReconnectAttempt(attempt: number): Promise { + private async runReconnectAttempt(): Promise { + const connectGeneration = ++this.connectGeneration try { // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. this.state.reconnectAttempt = 0 - await this.attemptConnect() + await this.attemptConnect(connectGeneration) + if (!this.isCurrentConnectAttempt(connectGeneration)) { + return + } + // Why: attemptConnect resolves only after a real handshake on either transport; the system-ssh probe's 'connected' must not clear the failure streak. + this.reconnectLadder.markConnected(Date.now()) } catch (err) { - if (this.disposed) { + // Why: a superseded attempt has no outcome to publish — the attempt that claimed the generation owns the state, and cancellation is that supersession. + if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { return } const error = err instanceof Error ? err : new Error(String(err)) + if (isCancelledConnectAttemptError(error)) { + return + } if (isAuthError(error) || isPassphraseError(error)) { this.setState('auth-failed', error.message) return } - if (!isTransientError(error)) { + // Why: the system-SSH transport reports timeouts as prose, so the narrow code table would strand FIDO2 targets in 'error' forever. + if (!isTransientReconnectError(error)) { this.setState('error', error.message) return } - this.state.reconnectAttempt = attempt + 1 + this.reconnectLadder.markAttemptFailed() this.scheduleReconnect() } } @@ -1383,6 +1433,7 @@ export class SshConnection { this.systemSshControlMasterDisabledForSession = false this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false + this.reconnectLadder.reset() this.setState('disconnected') } diff --git a/src/main/ssh/ssh-pty-consumer-recovery.test.ts b/src/main/ssh/ssh-pty-consumer-recovery.test.ts new file mode 100644 index 00000000000..09405f9ee0b --- /dev/null +++ b/src/main/ssh/ssh-pty-consumer-recovery.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session' +import { + claimSshPtyConsumerRecovery, + detachSshPtyConsumerRecovery, + rememberSshPtyConsumerRecovery +} from './ssh-pty-consumer-recovery' + +describe('SSH PTY consumer recovery', () => { + it('keeps a detached identity when a concurrent open finishes late', async () => { + const targetId = 'remember-detach-race' + const store = { + getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), + upsertSshPtyConsumerRecovery: vi.fn() + } as unknown as Store + const claimed = claimSshPtyConsumerRecovery(targetId, store) + let finishOpen!: (owner: SshPtyConsumerOwnerState) => void + const opened = new Promise((resolve) => { + finishOpen = resolve + }) + const remembering = opened.then((owner) => + rememberSshPtyConsumerRecovery({ + targetId, + clientInstanceId: claimed.clientInstanceId, + serverBuildId: 'relay-build', + owner, + store + }) + ) + + detachSshPtyConsumerRecovery(targetId, claimed.clientInstanceId) + finishOpen({ + mode: 'negotiated', + clientInstanceId: claimed.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'late-owner' + }) + await remembering + + expect(store.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() + expect(claimSshPtyConsumerRecovery(targetId, store)).toBe(claimed) + }) +}) diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts index 7b3fe215b76..1c357d32a12 100644 --- a/src/main/ssh/ssh-pty-consumer-recovery.ts +++ b/src/main/ssh/ssh-pty-consumer-recovery.ts @@ -56,21 +56,22 @@ export function getSshPtyConsumerRecovery( return recoveryByTarget.get(targetId) } -export function rememberSshPtyConsumerRecovery(args: { +// Why async: the in-memory update lands synchronously (before the first await) so no caller can +// observe a torn state; only the awaited durability barrier is deferred. +export async function rememberSshPtyConsumerRecovery(args: { targetId: string clientInstanceId: string serverBuildId: string owner: SshPtyConsumerOwnerState store: Store -}): void { +}): Promise { const current = recoveryByTarget.get(args.targetId) - if (current?.clientInstanceId !== args.clientInstanceId) { + if (current?.clientInstanceId !== args.clientInstanceId || current.detached) { return } - current.detached = false current.serverBuildId = args.serverBuildId current.owner = args.owner - args.store.upsertSshPtyConsumerRecovery({ + await args.store.upsertSshPtyConsumerRecovery({ targetId: args.targetId, clientInstanceId: args.clientInstanceId, serverBuildId: args.serverBuildId, @@ -81,14 +82,14 @@ export function rememberSshPtyConsumerRecovery(args: { }) } -export function removeSshPtyConsumerOwnerRecovery( +export async function removeSshPtyConsumerOwnerRecovery( targetId: string, clientInstanceId: string, store: Store -): void { +): Promise { const persisted = store.getSshPtyConsumerRecovery(targetId) if (persisted?.clientInstanceId === clientInstanceId) { - store.removeSshPtyConsumerRecovery(targetId) + await store.removeSshPtyConsumerRecovery(targetId) } } @@ -99,14 +100,14 @@ export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: } } -export function forgetSshPtyConsumerRecovery( +export async function forgetSshPtyConsumerRecovery( targetId: string, clientInstanceId: string, store: Store -): void { +): Promise { const current = recoveryByTarget.get(targetId) if (current?.clientInstanceId === clientInstanceId) { recoveryByTarget.delete(targetId) } - removeSshPtyConsumerOwnerRecovery(targetId, clientInstanceId, store) + await removeSshPtyConsumerOwnerRecovery(targetId, clientInstanceId, store) } diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 4bfebc6360f..8bb272d7273 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -2,7 +2,6 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, type PtyConsumerSessionGrant } from '../../shared/pty-consumer-session' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' export const SSH_PTY_OPEN_CLIENT_METHOD = 'pty.openClient' @@ -140,5 +139,3 @@ export async function openSshPtyConsumerSession( : {}) } } - -export const SSH_PTY_SOURCE_WINDOW_SU = DEFAULT_PTY_SOURCE_WINDOW_SU diff --git a/src/main/ssh/ssh-reconnect-error-classification.test.ts b/src/main/ssh/ssh-reconnect-error-classification.test.ts new file mode 100644 index 00000000000..cf8319db03c --- /dev/null +++ b/src/main/ssh/ssh-reconnect-error-classification.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { isTransientError } from './ssh-connection-utils' +import { + isDefiniteSystemSshHostFailure, + isTransientReconnectError +} from './ssh-reconnect-error-classification' + +describe('isTransientReconnectError', () => { + it('treats the system SSH connect timeout as recoverable', () => { + const err = new Error('System SSH connection timed out') + // Guards the split: widening isTransientError would spend 5 connect() attempts on this. + expect(isTransientError(err)).toBe(false) + expect(isTransientReconnectError(err)).toBe(true) + }) + + it.each([ + 'System SSH probe failed (exit 255).', + 'System SSH probe failed (exit 255). stderr: ssh: connect to host box port 22: Connection refused', + 'System SSH probe failed (exit 255). stderr: ssh: connect to host box port 22: No route to host', + 'System SSH probe failed (exit 255). stderr: ssh: connect to host box port 22: Network is unreachable', + 'System SSH probe failed (exit 255). stderr: kex_exchange_identification: read: Connection reset by peer', + 'System SSH probe failed (exit 255). stderr: ssh: Could not resolve hostname box: Name or service not known', + 'System SSH probe failed (exit 255). stderr: ssh_exchange_identification: read: Connection reset by peer', + 'System SSH probe failed (exit 255). stderr: ssh_exchange_identification: Connection closed by remote host', + 'System SSH probe failed (exit 255). stderr: Connection closed by remote host', + 'System SSH probe failed (exit 255). stderr: Lost connection', + 'System SSH probe failed (exit 255). stderr: Remote end closed connection' + ])('treats OpenSSH network prose as recoverable: %s', (message) => { + expect(isTransientReconnectError(new Error(message))).toBe(true) + }) + + it('keeps the bare probe failure narrow to the reconnect classifier', () => { + const err = new Error('System SSH probe failed (exit 255).') + expect(isTransientError(err)).toBe(false) + expect(isTransientReconnectError(err)).toBe(true) + }) + + // A server-side rejection prints the same verb without "remote"; retrying it forever would hide a + // real misconfiguration behind the ladder. + it('keeps a bare "Connection closed by port " permanent', () => { + expect( + isTransientReconnectError( + new Error( + 'System SSH probe failed (exit 255). stderr: Connection closed by 10.0.0.4 port 22' + ) + ) + ).toBe(false) + }) + + it('keeps credential failures permanent', () => { + expect( + isTransientReconnectError(new Error('All configured authentication methods failed')) + ).toBe(false) + expect( + isTransientReconnectError( + new Error('System SSH probe failed (exit 255). stderr: Permission denied (publickey).') + ) + ).toBe(false) + expect( + isTransientReconnectError( + new Error('Encrypted private OpenSSH key detected, but no passphrase given') + ) + ).toBe(false) + }) + + it.each([ + 'ssh: connect to host box port 22: Connection refused', + 'ssh: connect to host box port 22: Connection reset by peer', + 'ssh: connect to host box port 22: Connection timed out', + 'ssh: connect to host box port 22: No route to host', + 'ssh: Could not resolve hostname box: Name or service not known' + ])('recognizes definite host failures for the ControlMaster retry gate: %s', (message) => { + expect(isDefiniteSystemSshHostFailure(new Error(message))).toBe(true) + }) + + it.each([ + 'System SSH connection timed out', + 'Connection refused', + 'Connection reset by peer', + 'mux client failed: master is unresponsive' + ])('keeps potentially mux-shaped failures eligible for direct retry: %s', (message) => { + expect(isDefiniteSystemSshHostFailure(new Error(message))).toBe(false) + }) + + it('still covers the errno codes isTransientError already matched', () => { + const err = new Error('refused') as NodeJS.ErrnoException + err.code = 'ECONNREFUSED' + expect(isTransientReconnectError(err)).toBe(true) + }) + + it('keeps unrelated failures permanent', () => { + expect(isTransientReconnectError(new Error('something went wrong'))).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-reconnect-error-classification.ts b/src/main/ssh/ssh-reconnect-error-classification.ts new file mode 100644 index 00000000000..dce71dc6dae --- /dev/null +++ b/src/main/ssh/ssh-reconnect-error-classification.ts @@ -0,0 +1,83 @@ +import { isAuthError, isPassphraseError, isTransientError } from './ssh-connection-utils' + +// Why: the system-SSH transport reports network failures as OpenSSH prose, not errno codes, so its +// probe timeouts and connect failures never match isTransientError's code table. +const NETWORK_LIKE_ERROR_FRAGMENTS = [ + 'system ssh connection timed out', + 'timed out while waiting for handshake', + 'connection timed out', + 'operation timed out', + 'connection refused', + 'connection reset', + 'no route to host', + 'network is unreachable', + 'network is down', + 'host is down', + 'broken pipe', + 'temporary failure in name resolution', + 'name or service not known', + 'nodename nor servname', + 'could not resolve hostname', + 'kex_exchange_identification', + // Pre-7.x OpenSSH wording for the same banner-exchange failure. + 'ssh_exchange_identification', + 'lost connection', + 'remote end closed', + // Deliberately not the bare 'connection closed by': OpenSSH prints "Connection closed by port 22" + // for server-side rejections (MaxStartups, DenyUsers) too, and those must stay permanent. + 'connection closed by remote' +] + +const DEFINITE_HOST_FAILURE_FRAGMENTS = [ + 'no route to host', + 'network is unreachable', + 'network is down', + 'host is down', + 'temporary failure in name resolution', + 'name or service not known', + 'nodename nor servname', + 'could not resolve hostname' +] + +const DEFINITE_HOST_FAILURE_CODES = new Set(['EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN']) + +const OPENSSH_HOST_CONNECT_FAILURE = + /\bconnect to host .+? port \d+: (?:connection (?:timed out|refused|reset(?: by peer)?)|operation timed out)\b/i + +const EMPTY_SYSTEM_SSH_PROBE_EXIT_255 = /^system ssh probe failed \(exit 255\)\.$/ + +/** + * Why a second classifier: connect() spends isTransientError on INITIAL_RETRY_ATTEMPTS, so widening + * it would turn one 30s system-SSH timeout into ~160s of silence and five security-key touch + * prompts. The reconnect ladder has its own backoff and give-up bound, so it can afford to treat a + * network-shaped failure as recoverable. Auth stays permanent on both paths. + */ +export function isTransientReconnectError(err: Error): boolean { + if (isAuthError(err) || isPassphraseError(err)) { + return false + } + if (isTransientError(err)) { + return true + } + const message = err.message.toLowerCase() + return ( + EMPTY_SYSTEM_SSH_PROBE_EXIT_255.test(message) || + NETWORK_LIKE_ERROR_FRAGMENTS.some((fragment) => message.includes(fragment)) + ) +} + +/** Definite host failures where a ControlMaster-free retry cannot help. */ +export function isDefiniteSystemSshHostFailure(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const code = (err as NodeJS.ErrnoException).code + if (code && DEFINITE_HOST_FAILURE_CODES.has(code)) { + return true + } + const message = err.message.toLowerCase() + return ( + DEFINITE_HOST_FAILURE_FRAGMENTS.some((fragment) => message.includes(fragment)) || + OPENSSH_HOST_CONNECT_FAILURE.test(err.message) + ) +} diff --git a/src/main/ssh/ssh-reconnect-ladder.test.ts b/src/main/ssh/ssh-reconnect-ladder.test.ts new file mode 100644 index 00000000000..c43d4e9e6e9 --- /dev/null +++ b/src/main/ssh/ssh-reconnect-ladder.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' +import { + FLAP_DELAY_CAP_MS, + RELAY_REESTABLISH_BUDGET_MS, + SshReconnectLadder, + STABLE_CONNECTION_MS +} from './ssh-reconnect-ladder' + +describe('SshReconnectLadder', () => { + it('climbs the table across consecutive post-handshake drops', () => { + const ladder = new SshReconnectLadder() + + ladder.markConnected(0) + expect(ladder.next(50)).toEqual({ kind: 'retry', delayMs: 1000, attemptIndex: 0 }) + ladder.markConnected(60) + expect(ladder.next(110)).toEqual({ kind: 'retry', delayMs: 2000, attemptIndex: 1 }) + + // Table steps past FLAP_DELAY_CAP_MS are clamped so the retry still fits inside the relay grace floor. + const rest = [5000, 5000, 10000, 10000, 10000, 30000, 30000] + let now = 200 + rest.forEach((delayMs, index) => { + ladder.markConnected(now) + now += 50 + expect(ladder.next(now)).toEqual({ + kind: 'retry', + delayMs: Math.min(delayMs, FLAP_DELAY_CAP_MS), + attemptIndex: index + 2 + }) + }) + + // Saturates at the last step instead of going terminal. + for (let i = 0; i < 3; i++) { + ladder.markConnected(now) + now += 50 + expect(ladder.next(now)).toEqual({ + kind: 'retry', + delayMs: Math.min(30000, FLAP_DELAY_CAP_MS), + attemptIndex: 8 + }) + } + }) + + it('caps every flap delay so handshake and relay re-establishment beat grace', () => { + const ladder = new SshReconnectLadder() + let now = 0 + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length + 4; i++) { + ladder.markConnected(now) + now += 50 + const decision = ladder.next(now) + expect(decision.kind).toBe('retry') + if (decision.kind === 'retry') { + expect(decision.delayMs + CONNECT_TIMEOUT_MS + RELAY_REESTABLISH_BUDGET_MS).toBeLessThan( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 + ) + now += decision.delayMs + } + } + expect(FLAP_DELAY_CAP_MS).toBe(5_000) + }) + + it('keeps the uncapped table for a host whose handshakes fail', () => { + const ladder = new SshReconnectLadder() + const delays: number[] = [] + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length - 1; i++) { + ladder.markAttemptFailed() + const decision = ladder.next(i * 1000) + if (decision.kind === 'retry') { + delays.push(decision.delayMs) + } + expect(ladder.failedAttemptStreak).toBe(i + 1) + } + + expect(delays).toEqual(RECONNECT_BACKOFF_MS.slice(0, -1)) + expect(delays.some((delayMs) => delayMs > FLAP_DELAY_CAP_MS)).toBe(true) + }) + + it('escalates a post-handshake drop identically to a handshake failure', () => { + const dropLadder = new SshReconnectLadder() + const failureLadder = new SshReconnectLadder() + const dropDelays: number[] = [] + const dropIndexes: number[] = [] + const failureIndexes: number[] = [] + + let now = 0 + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + dropLadder.markConnected(now) + now += 50 + const dropDecision = dropLadder.next(now) + const failureDecision = failureLadder.next(now) + expect(dropDecision.kind).toBe('retry') + expect(failureDecision.kind).toBe('retry') + if (dropDecision.kind === 'retry') { + dropDelays.push(dropDecision.delayMs) + dropIndexes.push(dropDecision.attemptIndex) + } + if (failureDecision.kind === 'retry') { + failureIndexes.push(failureDecision.attemptIndex) + } + failureLadder.markAttemptFailed() + now += 50 + } + + // Same ladder position; the flap path only clamps the wall-clock delay to the grace budget. + expect(dropIndexes).toEqual(failureIndexes) + expect(dropDelays).toEqual(RECONNECT_BACKOFF_MS.map((d) => Math.min(d, FLAP_DELAY_CAP_MS))) + }) + + it('never reaches give-up on a flap streak, even with a later handshake failure', () => { + const ladder = new SshReconnectLadder() + let now = 0 + for (let i = 0; i < 12; i++) { + ladder.markConnected(now) + now += 50 + const decision = ladder.next(now) + expect(decision.kind).toBe('retry') + if (decision.kind === 'retry') { + now += decision.delayMs + } + } + + ladder.markAttemptFailed() + + expect(ladder.next(now)).toEqual({ kind: 'retry', delayMs: 30000, attemptIndex: 8 }) + }) + + it('gives up only after RECONNECT_BACKOFF_MS.length consecutive failed attempts', () => { + const ladder = new SshReconnectLadder() + const decisions: string[] = [] + + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + decisions.push(ladder.next(i * 1000).kind) + } + + expect(decisions.slice(0, RECONNECT_BACKOFF_MS.length - 1)).toEqual( + Array(RECONNECT_BACKOFF_MS.length - 1).fill('retry') + ) + expect(decisions.at(-1)).toBe('give-up') + }) + + it('resets the delay ladder exactly once for a stable connection', () => { + const ladder = new SshReconnectLadder() + for (let i = 0; i < 5; i++) { + ladder.next(i) + } + + ladder.markConnected(0) + expect(ladder.next(STABLE_CONNECTION_MS)).toEqual({ + kind: 'retry', + delayMs: 1000, + attemptIndex: 0 + }) + // The consumed timestamp must not reset the ladder again later in the same outage. + expect(ladder.next(STABLE_CONNECTION_MS * 4)).toEqual({ + kind: 'retry', + delayMs: 2000, + attemptIndex: 1 + }) + }) + + it('does not let a dead host self-reset past the give-up bound', () => { + const ladder = new SshReconnectLadder() + ladder.markConnected(0) + + let now = 0 + let last = ladder.next(now) + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + now += STABLE_CONNECTION_MS * 2 + last = ladder.next(now) + } + + expect(last).toEqual({ kind: 'give-up' }) + }) + + it('returns to the head of the ladder after reset()', () => { + const ladder = new SshReconnectLadder() + for (let i = 0; i < RECONNECT_BACKOFF_MS.length; i++) { + ladder.markAttemptFailed() + ladder.next(i) + } + + ladder.reset() + + expect(ladder.next(0)).toEqual({ kind: 'retry', delayMs: 1000, attemptIndex: 0 }) + }) +}) diff --git a/src/main/ssh/ssh-reconnect-ladder.ts b/src/main/ssh/ssh-reconnect-ladder.ts new file mode 100644 index 00000000000..bf03154be0e --- /dev/null +++ b/src/main/ssh/ssh-reconnect-ladder.ts @@ -0,0 +1,78 @@ +import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { CONNECT_TIMEOUT_MS, RECONNECT_BACKOFF_MS } from './ssh-connection-utils' + +// A connection that held this long is treated as healthy, so the next drop restarts the delay ladder. +export const STABLE_CONNECTION_MS = 60_000 +// Existing-relay attach and one bounded PTY reattach attempt each allow 10s. +export const RELAY_REESTABLISH_BUDGET_MS = 20_000 + +/** + * Why: a flap leaves the remote relay in grace, and the shortest grace a target can configure is + * MIN_SSH_RELAY_GRACE_PERIOD_SECONDS. Retrying later than this cap would let even a full-length + * handshake plus relay/mux re-establishment finish after grace expires, so the relay would shut + * down and take every remote PTY with it. Raising the backoff past this bound changes nothing. + */ +export const FLAP_DELAY_CAP_MS = (() => { + const budgetMs = + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 - CONNECT_TIMEOUT_MS - RELAY_REESTABLISH_BUDGET_MS + const fitting = RECONNECT_BACKOFF_MS.filter((delayMs) => delayMs < budgetMs) + return fitting.length > 0 ? Math.max(...fitting) : RECONNECT_BACKOFF_MS[0] +})() + +export type SshReconnectDecision = + | { kind: 'retry'; delayMs: number; attemptIndex: number } + | { kind: 'give-up' } + +/** + * Why two counters: `delayIndex` advances on every scheduled retry (flap or handshake failure) so a + * host that keeps dropping after a successful handshake still backs off, while + * `consecutiveFailedAttempts` advances only on a failed connect attempt so 'reconnection-failed' + * stays reachable exactly after RECONNECT_BACKOFF_MS.length failed handshakes — never after flaps. + */ +export class SshReconnectLadder { + private delayIndex = 0 + private consecutiveFailedAttempts = 0 + private connectedAtMs: number | null = null + + next(nowMs: number): SshReconnectDecision { + const connectedAt = this.connectedAtMs + // Why: consume it — one reset per connection, never a rolling reset mid-outage (the table sums past the window). + this.connectedAtMs = null + if (connectedAt !== null && nowMs - connectedAt >= STABLE_CONNECTION_MS) { + this.delayIndex = 0 + } + if (this.consecutiveFailedAttempts >= RECONNECT_BACKOFF_MS.length) { + return { kind: 'give-up' } + } + const attemptIndex = Math.min(this.delayIndex, RECONNECT_BACKOFF_MS.length - 1) + this.delayIndex = attemptIndex + 1 + const tableDelayMs = RECONNECT_BACKOFF_MS[attemptIndex] + // Why: only a flap (no failed handshake) means the remote relay is alive and burning grace; a dead + // host keeps the full table because nothing over there is waiting for us. + const delayMs = + this.consecutiveFailedAttempts === 0 + ? Math.min(tableDelayMs, FLAP_DELAY_CAP_MS) + : tableDelayMs + return { kind: 'retry', delayMs, attemptIndex } + } + + get failedAttemptStreak(): number { + return this.consecutiveFailedAttempts + } + + markConnected(nowMs: number): void { + this.consecutiveFailedAttempts = 0 + this.connectedAtMs = nowMs + } + + markAttemptFailed(): void { + this.consecutiveFailedAttempts += 1 + this.connectedAtMs = null + } + + reset(): void { + this.delayIndex = 0 + this.consecutiveFailedAttempts = 0 + this.connectedAtMs = null + } +} diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index de4f00fdf08..f92b72b09a8 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -154,7 +154,9 @@ function createSession(targetId: string): InstanceType { removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() + markSshRemotePtyLeases: vi.fn(), + markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesAttachedAsync: vi.fn() } as unknown as Store const portForwardManager = { removeAllForwards: vi.fn().mockResolvedValue(undefined) @@ -481,6 +483,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', toolAgentType: 'Review', + claudeRunningNonAgentTask: true, providerSessionOnly: true, providerSession: { key: 'session_id', @@ -503,6 +506,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', toolAgentType: 'Review', + claudeRunningNonAgentTask: true, providerSessionOnly: true, providerSession: { key: 'session_id', diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 19939ae3857..56f6c6eeff2 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -819,7 +819,7 @@ describe('SshRelaySession data delivery', () => { ) expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'detached') expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(targetId, 'pty-1', 'expired') - expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() expect(clearProviderPtyState).not.toHaveBeenCalled() expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() expect(deletePtyOwnership).not.toHaveBeenCalled() @@ -836,10 +836,8 @@ describe('SshRelaySession data delivery', () => { }) expect(acceptOutputDataMock.mock.calls.map(([payload]) => payload.data)).toEqual(['live']) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( - 'empty-recovery', - 'pty-1', - 'attached' - ) + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('empty-recovery', [ + 'pty-1' + ]) }) }) diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 95492aa3a3a..b0acfeb29e2 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -15,7 +15,6 @@ const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) vi.mock('./ssh-pty-consumer-session', () => ({ - SSH_PTY_SOURCE_WINDOW_SU: 256 * 1024, openSshPtyConsumerSession: openConsumerSessionMock })) vi.mock('./ssh-channel-multiplexer', () => ({ diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index c624e72564d..c34c4298dd8 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -220,12 +220,8 @@ describe('SshRelaySession reconnect incarnation ordering', () => { const reconnect = session.reconnect(mockConn) await vi.advanceTimersByTimeAsync(750) - expect( - vi - .mocked(mockStore.markSshRemotePtyLease) - .mock.calls.filter(([, , state]) => state === 'attached') - .map(([, id]) => id) - ).toHaveLength(48) + expect(setPtyOwnership).toHaveBeenCalledTimes(48) + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() expect(peakActive).toBeLessThanOrEqual(8) await vi.advanceTimersByTimeAsync(20_000) @@ -235,6 +231,11 @@ describe('SshRelaySession reconnect incarnation ordering', () => { expect(attempts.get('pty-0')).toBe(2) expect(attempts.get('pty-1')).toBe(2) expect(session.getState()).toBe('ready') + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( + 'target-1', + expect.arrayContaining(ptyIds.slice(2)) + ) expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( 'target-1', expect.any(String), @@ -420,7 +421,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { incarnationId }) expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(mockStore.markSshRemotePtyLease).mock.invocationCallOrder[0]! + vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! ) }) @@ -564,7 +565,9 @@ describe('SshRelaySession reconnect incarnation ordering', () => { leafId: INCARNATION_LEAF_ID, incarnationId }) - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached') + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ + 'pty-live' + ]) expect(consoleError).toHaveBeenCalledWith( '[ssh-relay-session] Failed to persist reconnect incarnation:', expect.any(Error) diff --git a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts new file mode 100644 index 00000000000..df7bce635ec --- /dev/null +++ b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { + createMismatchedOwnerRecoveryError, + createMockDeps +} from './ssh-relay-session-test-fixtures' +import { getSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' + +const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn() +})) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-pty-consumer-session', () => ({ + openSshPtyConsumerSession: openConsumerSessionMock +})) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ + acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), + acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), + allocateSshPtyProviderGeneration: vi.fn(() => 23), + beginSshPtyOutputGenerationMigration: vi.fn(() => ({ + byPty: new Map(), + completion: Promise.resolve() + })), + closeSshPtyOutputGeneration: vi.fn(), + getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), + installSshPtySourceAckPublisher: vi.fn(() => () => {}), + installSshPtySourceCancellationPublisher: vi.fn(() => () => {}), + applySshPtySourceCancellationProof: vi.fn(), + applySshPtySourceRecoveryCancellationProof: vi.fn() +})) + +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + notifyWithSettlement = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onNotificationByMethod = vi.fn().mockReturnValue(() => {}) + onRequest = vi.fn().mockReturnValue(() => {}) + onDispose = vi.fn().mockReturnValue(() => {}) + dispose = vi.fn() + isDisposed = vi.fn().mockReturnValue(false) + } +})) + +vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({ + installRemoteManagedAgentHooks: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../providers/ssh-pty-provider', () => ({ + isSshPtyNotFoundError: vi.fn().mockReturnValue(false), + isSshPtyIdentityMismatchError: vi.fn().mockReturnValue(false), + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + setPtyDeliveryPauseAdapter = vi.fn() + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-filesystem-provider', () => ({ + SshFilesystemProvider: class MockSshFilesystemProvider { + dispose = vi.fn() + } +})) + +vi.mock('../providers/ssh-git-provider', () => ({ + SshGitProvider: class MockSshGitProvider {} +})) + +vi.mock('../ipc/pty', () => ({ + registerSshPtyProvider: vi.fn(), + unregisterSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), + getPtyIdsForConnection: vi.fn().mockReturnValue([]), + clearPtyOwnershipForConnection: vi.fn(), + clearProviderPtyState: vi.fn(), + deletePtyOwnership: vi.fn(), + restorePtyIncarnation: vi.fn(), + setPtyOwnership: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + registerSshFilesystemProvider: vi.fn(), + unregisterSshFilesystemProvider: vi.fn(), + getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + registerSshGitProvider: vi.fn(), + unregisterSshGitProvider: vi.fn() +})) + +const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +const { clearPtyOwnershipForConnection, unregisterSshPtyProvider } = await import('../ipc/pty') + +describe('SshRelaySession consumer recovery durability', () => { + beforeEach(() => { + vi.clearAllMocks() + muxRequestMock.mockResolvedValue([]) + openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + })) + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + platform: 'linux-x64', + serverBuildId: 'test-relay-build' + }) + }) + + it('holds establish open until the consumer recovery write is durable', async () => { + const deps = createMockDeps() + let settleWrite!: () => void + let signalWriteStarted!: () => void + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve + }) + vi.mocked(deps.mockStore.upsertSshPtyConsumerRecovery).mockImplementation(() => { + signalWriteStarted() + return new Promise((resolve) => { + settleWrite = resolve + }) + }) + + const session = new SshRelaySession( + 'durability-barrier-target', + deps.getMainWindow, + deps.mockStore, + deps.mockPortForward + ) + let established = false + const establishing = session.establish(deps.mockConn).then(() => { + established = true + }) + + await writeStarted + // Why a macrotask, not a microtask count: establish() has several awaits after the write starts, + // so only yielding past the whole microtask queue proves the write is the thing blocking it. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(established).toBe(false) + + settleWrite() + await establishing + expect(established).toBe(true) + session.dispose() + }) + + it('keeps destructive disposal pending until consumer recovery is removed', async () => { + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId: 'target-disposal-durability', + clientInstanceId: 'client-disposal-durability', + serverBuildId: 'test-relay-build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-lease' + }) + let settleRemoval!: () => void + vi.mocked(mockStore.removeSshPtyConsumerRecovery).mockImplementation( + () => + new Promise((resolve) => { + settleRemoval = resolve + }) + ) + const session = new SshRelaySession( + 'target-disposal-durability', + getMainWindow, + mockStore, + mockPortForward + ) + let completed = false + + const disposal = session.disposeAndPersist().then(() => { + completed = true + }) + await Promise.resolve() + + expect(session.getState()).toBe('disposed') + expect(completed).toBe(false) + expect(mockStore.markSshRemotePtyLeases).not.toHaveBeenCalled() + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith( + 'target-disposal-durability', + 'terminated' + ) + + settleRemoval() + await disposal + expect(completed).toBe(true) + }) + + it('does not retry a stale owner after disposal wins the recovery-removal race', async () => { + const targetId = 'target-stale-owner-disposal' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId, + clientInstanceId: 'persisted-client', + serverBuildId: 'test-relay-build', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'stale-owner' + }) + let signalRemovalStarted!: () => void + const removalStarted = new Promise((resolve) => { + signalRemovalStarted = resolve + }) + let settleRemoval!: () => void + vi.mocked(mockStore.removeSshPtyConsumerRecovery).mockImplementationOnce(() => { + signalRemovalStarted() + return new Promise((resolve) => { + settleRemoval = resolve + }) + }) + openConsumerSessionMock.mockRejectedValueOnce(createMismatchedOwnerRecoveryError()) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + + const establishing = session.establish(mockConn) + const failed = expect(establishing).rejects.toThrow('Session disposed during establish') + await removalStarted + const disposal = session.disposeAndPersist() + settleRemoval() + await Promise.all([failed, disposal]) + + expect(openConsumerSessionMock).toHaveBeenCalledTimes(1) + }) + + it('does not remember a consumer opened after establish was disposed', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + let signalOpenStarted!: () => void + const openStarted = new Promise((resolve) => { + signalOpenStarted = resolve + }) + let finishOpen!: (value: unknown) => void + openConsumerSessionMock.mockImplementationOnce(() => { + signalOpenStarted() + return new Promise((resolve) => { + finishOpen = resolve + }) + }) + const session = new SshRelaySession( + 'target-open-disposal', + getMainWindow, + mockStore, + mockPortForward + ) + + const establishing = session.establish(mockConn) + const failed = expect(establishing).rejects.toThrow('Session disposed during establish') + await openStarted + await session.disposeAndPersist() + finishOpen({ + mode: 'negotiated', + clientInstanceId: 'late-client', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'late-owner' + }) + await failed + + expect(mockStore.upsertSshPtyConsumerRecovery).not.toHaveBeenCalled() + }) + + it('upgrades a pending detach to a full disposal', async () => { + const targetId = 'target-teardown-upgrade' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + let settleDetachPersistence!: () => void + let settleDisposalPersistence!: () => void + vi.mocked(mockStore.markSshRemotePtyLeasesAsync) + .mockImplementationOnce( + () => + new Promise((resolve) => { + settleDetachPersistence = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + settleDisposalPersistence = resolve + }) + ) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + let detachCompleted = false + const detach = session.detachAndPersist().then(() => { + detachCompleted = true + }) + const disposal = session.disposeAndPersist() + + // Why: dispose supersedes the in-flight detach, so the destructive half must still run. + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') + expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() + // Why: 'shutdown' teardown, not detach's 'connection_lost' — PTY ownership is released for good. + expect(clearPtyOwnershipForConnection).toHaveBeenCalledWith(targetId) + + settleDetachPersistence() + await Promise.resolve() + await Promise.resolve() + expect(detachCompleted).toBe(false) + settleDisposalPersistence() + await Promise.all([detach, disposal]) + + // Why: the reverse order is not an upgrade — a detach after disposal must not re-open ownership. + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() + await session.detachAndPersist() + expect(mockStore.markSshRemotePtyLeasesAsync).not.toHaveBeenCalled() + }) + + it('re-issues only the lease write after a rejected detach persistence', async () => { + const targetId = 'target-detach-write-retry' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( + new Error('lease write failed') + ) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + vi.mocked(clearPtyOwnershipForConnection).mockClear() + + await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') + + const teardownCalls = vi.mocked(unregisterSshPtyProvider).mock.calls.length + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockClear() + await session.detachAndPersist() + + // Why: the retry re-issues the write only — re-running provider teardown would tear down + // whatever a replacement session has already registered for this target. + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'detached') + expect(unregisterSshPtyProvider).toHaveBeenCalledTimes(teardownCalls) + expect(clearPtyOwnershipForConnection).not.toHaveBeenCalled() + }) + + it('still upgrades to disposal after a rejected detach persistence', async () => { + const targetId = 'target-detach-write-failure-disposal' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.markSshRemotePtyLeasesAsync).mockRejectedValueOnce( + new Error('lease write failed') + ) + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + await expect(session.detachAndPersist()).rejects.toThrow('lease write failed') + await session.disposeAndPersist() + + expect(mockStore.markSshRemotePtyLeasesAsync).toHaveBeenCalledWith(targetId, 'terminated') + expect(getSshPtyConsumerRecovery(targetId)).toBeUndefined() + }) +}) diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 43354ebb90c..81039d901ec 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -279,11 +279,7 @@ describe('SshRelaySession recovery race fencing', () => { expect(recoveryActivationLease.retire).not.toHaveBeenCalled() expect(muxRequestMock).not.toHaveBeenCalledWith('pty.cancelDelivery', expect.anything()) expect(setPtyOwnership).not.toHaveBeenCalled() - expect(deps.mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - targetId, - 'pty-1', - 'attached' - ) + expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() }) it('settles exact cancellation before publishing an exit with incomplete recovery data', async () => { @@ -775,8 +771,10 @@ describe('SshRelaySession recovery race fencing', () => { expect(muxRequestMock.mock.calls.filter(([method]) => method === 'pty.cancelDelivery')).toEqual( [] ) - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledTimes(1) - expect(deps.mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(targetId, 'pty-1', 'attached') + expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() + expect(deps.mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith(targetId, [ + 'pty-1' + ]) expect(setPtyOwnership).toHaveBeenCalledTimes(1) expect(staleLease.transferToRecovery).toHaveBeenCalledOnce() expect(staleLease.commit).not.toHaveBeenCalled() diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 24d95fe18a5..a0f855543ef 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -105,7 +105,9 @@ function createMockDeps(): { removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() + markSshRemotePtyLeases: vi.fn(), + markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesAttachedAsync: vi.fn() } as unknown as Store const mockPortForward = { removeAllForwards: vi.fn() diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index 41c4bca32d4..b0ed98eaf2e 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -24,6 +24,8 @@ export function createMockDeps(): SshRelaySessionTestDeps { getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), + markSshRemotePtyLeasesAsync: vi.fn(), + markSshRemotePtyLeasesAttachedAsync: vi.fn(), persistPtyBinding: vi.fn() } as unknown as Store const mockPortForward = { diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index ad563d002b5..570fca21fd4 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -21,7 +21,6 @@ vi.mock('./ssh-relay-deploy', () => ({ })) vi.mock('./ssh-pty-consumer-session', () => ({ - SSH_PTY_SOURCE_WINDOW_SU: 256 * 1024, openSshPtyConsumerSession: openConsumerSessionMock })) @@ -497,7 +496,9 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-1') expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-1', 'target-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-1', 'attached') + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith('target-1', [ + 'pty-1' + ]) }) it('establish re-attaches durable leases after app restart', async () => { @@ -511,6 +512,7 @@ describe('SshRelaySession', () => { vi.mocked(getPtyIdsForConnection).mockReturnValue([]) vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ { targetId: 'target-1', ptyId: 'pty-live', state: 'detached' }, + { targetId: 'target-1', ptyId: 'pty-live-2', state: 'detached' }, { targetId: 'target-1', ptyId: 'pty-expired', state: 'expired' } ] as ReturnType) @@ -519,9 +521,14 @@ describe('SshRelaySession', () => { await session.establish(mockConn) expect(mockAttach).toHaveBeenCalledWith('pty-live') + expect(mockAttach).toHaveBeenCalledWith('pty-live-2') expect(mockAttach).not.toHaveBeenCalledWith('pty-expired') expect(setPtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-live', 'target-1') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached') + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledOnce() + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).toHaveBeenCalledWith( + 'target-1', + expect.arrayContaining(['pty-live', 'pty-live-2']) + ) }) it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => { @@ -631,11 +638,7 @@ describe('SshRelaySession', () => { await expect(establish).rejects.toThrow('Session disposed during establish') expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'target-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-1', - 'attached' - ) + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() }) it('does not mark PTYs attached if detach wins while reattach is in flight', async () => { @@ -665,11 +668,7 @@ describe('SshRelaySession', () => { await reconnect expect(setPtyOwnership).not.toHaveBeenCalledWith('pty-1', 'target-1') - expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( - 'target-1', - 'pty-1', - 'attached' - ) + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() }) it('invalidates and broadcasts remote PTYs that cannot reattach after relay reconnect', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 5edce4306a6..d413f45f044 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -233,6 +233,27 @@ function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): ) } +// Why: teardown barriers are independent, so one failing store write must not hide the others — +// settle them all and aggregate, rather than rethrowing only whichever rejected first. +async function settleSshSessionTeardown( + barriers: (Promise | null | undefined)[] +): Promise { + const results = await Promise.allSettled(barriers.map((barrier) => barrier ?? Promise.resolve())) + const errors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason as unknown] : [] + ) + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'SSH relay session teardown failed') + } +} + +// Why: dispose is strictly more destructive than detach, so the mode records which teardown a +// session has already committed to and lets dispose supersede an in-flight detach. +type SshRelaySessionTeardownMode = 'detach' | 'dispose' + export class SshRelaySession { private _state: RelaySessionState = 'idle' private mux: SshChannelMultiplexer | null = null @@ -256,6 +277,12 @@ export class SshRelaySession { private activePtyProviderGeneration: number | null = null private sourceAckPublisherCleanup: (() => void) | null = null private sourceCancellationPublisherCleanup: (() => void) | null = null + private teardownMode: SshRelaySessionTeardownMode | null = null + private teardownCompletion: Promise | null = null + // Why: detach's in-memory half is one-shot but its lease write is retryable, so they are tracked + // apart — a rejected write can be re-issued without re-running provider teardown. + private detachedInMemory = false + private detachFlushRejected = false private ptyRecoveryNotificationCleanups: (() => void)[] = [] private readonly sourceIdentityByRelayPtyId = new Map< string, @@ -405,10 +432,27 @@ export class SshRelaySession { const mux = new SshChannelMultiplexer(transport) this.mux = mux - const ownsAttempt = (): boolean => this.mux === mux && !this.isDisposed() + const ownsAttempt = (): boolean => this.mux === mux && !mux.isDisposed() && !this.isDisposed() - this.ptyConsumerSessionState = await this.openPtyConsumerSession(mux, serverBuildId) - this.rememberPtyConsumerRecovery(serverBuildId) + const ptyConsumerSessionState = await this.openPtyConsumerSession( + mux, + serverBuildId, + ownsAttempt + ) + if (!ownsAttempt()) { + if (!mux.isDisposed()) { + mux.dispose() + } + throw new Error('Session disposed during establish') + } + this.ptyConsumerSessionState = ptyConsumerSessionState + await this.rememberPtyConsumerRecovery(serverBuildId) + if (!ownsAttempt()) { + if (!mux.isDisposed()) { + mux.dispose() + } + throw new Error('Session disposed during establish') + } await mux.request('session.resolveHome', { path: '~' }) if (!ownsAttempt()) { @@ -529,12 +573,25 @@ export class SshRelaySession { this.mux = mux const ownsAttempt = (): boolean => + this.mux === mux && + !mux.isDisposed() && this.abortController === abortController && !abortController.signal.aborted && !this.isDisposed() - this.ptyConsumerSessionState = await this.openPtyConsumerSession(mux, serverBuildId) - this.rememberPtyConsumerRecovery(serverBuildId) + const ptyConsumerSessionState = await this.openPtyConsumerSession( + mux, + serverBuildId, + ownsAttempt + ) + if (!ownsAttempt()) { + if (!mux.isDisposed()) { + mux.dispose() + } + return + } + this.ptyConsumerSessionState = ptyConsumerSessionState + await this.rememberPtyConsumerRecovery(serverBuildId) if (!ownsAttempt()) { if (!mux.isDisposed()) { mux.dispose() @@ -620,35 +677,126 @@ export class SshRelaySession { } } + /** Fire-and-forget disposal; prefer {@link disposeAndPersist} when the caller can await durability. */ dispose(): void { - if (this._state === 'disposed') { - return - } - this.abortController?.abort() - this.stopPortScanning() - // Why: fire-and-forget — nothing rebinds after dispose, so no need to await port release. - void this.portForwardManager.removeAllForwards(this.targetId) - this.broadcastEmptyLists() - this.teardownProviders('shutdown') - this.store.markSshRemotePtyLeases(this.targetId, 'terminated') - this.currentConnection = null - this._state = 'disposed' - forgetSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId, this.store) + void this.disposeAndPersist().catch((error) => { + console.warn( + `[ssh-relay-session] Failed to persist disposal for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` + ) + }) } - detach(): void { - if (this._state === 'disposed') { - return + /** + * Destructive teardown: forgets the consumer recovery record and terminates the leases. + * Repeat calls share one completion, and a dispose requested after a detach supersedes it — + * the destructive half re-runs so detach-then-dispose still forgets recovery and terminates + * leases. (The reverse, detach after dispose, is a no-op.) + */ + disposeAndPersist(): Promise { + if (this.teardownMode === 'dispose') { + return this.teardownCompletion ?? Promise.resolve() } + const pendingDetach = this.teardownCompletion + this.teardownMode = 'dispose' + try { + this.teardownCompletion = this.runDisposal(pendingDetach) + } catch (error) { + // Why: a synchronous failure in the in-memory half must ride the completion promise, or a later + // disposeAndPersist reports success on a null completion and detachAndPersist re-runs runDetach. + this.teardownCompletion = Promise.reject(error) + } + return this.teardownCompletion + } + + private runDisposal(pendingDetach: Promise | null): Promise { + // Why: the whole in-memory half runs before any await so a concurrent connect can never observe + // a half-torn session; only the durability barriers below are deferred onto the returned promise. this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() - // Why: window disconnect is non-destructive — unregister local providers but keep PTY ownership so reattach works (relay owns the grace timer). - this.teardownProviders('connection_lost') - this.store.markSshRemotePtyLeases(this.targetId, 'detached') + this.teardownProviders('shutdown') this.currentConnection = null this._state = 'disposed' - detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + const recoveryRemoval = forgetSshPtyConsumerRecovery( + this.targetId, + this.ptyConsumerClientInstanceId, + this.store + ) + const leaseTermination = this.store.markSshRemotePtyLeasesAsync(this.targetId, 'terminated') + return settleSshSessionTeardown([ + // Why: a superseded detach keeps its own rejection for its own caller; swallow it here so a + // failed detach write cannot fail the disposal that replaced it. + pendingDetach?.catch(() => undefined), + // Why: nothing rebinds after dispose, but direct callers (no IPC-side teardown) still need + // this session's local listeners released. + this.portForwardManager.removeAllForwards(this.targetId), + recoveryRemoval, + leaseTermination + ]) + } + + /** Fire-and-forget detach; prefer {@link detachAndPersist} when the caller can await durability. */ + detach(): void { + void this.detachAndPersist().catch((error) => { + console.warn( + `[ssh-relay-session] Failed to persist detach for ${this.targetId}: ${error instanceof Error ? error.message : String(error)}` + ) + }) + } + + /** + * Non-destructive teardown: keeps PTY ownership so a later connect reclaims this consumer + * identity. Once dispose has been requested this is a no-op — see {@link disposeAndPersist}. + */ + async detachAndPersist(): Promise { + // Why: a rejected lease write must not latch forever — re-issue just the write so the caller can + // retry. Never under 'dispose': that supersedes detach for good and re-issuing would resurrect + // the 'detached' state the disposal already replaced with 'terminated'. + if (!this.teardownCompletion || (this.teardownMode === 'detach' && this.detachFlushRejected)) { + this.teardownCompletion = this.runDetach() + } + this.teardownMode ??= 'detach' + let completion = this.teardownCompletion + while (completion) { + try { + await completion + } catch (error) { + if (completion === this.teardownCompletion) { + throw error + } + } + if (completion === this.teardownCompletion) { + return + } + completion = this.teardownCompletion + } + } + + private runDetach(): Promise { + if (!this.detachedInMemory) { + if (this._state === 'disposed') { + return Promise.resolve() + } + // Why first: same synchronous-half-first rule as runDisposal, and this is the highest-value + // step — a fast reconnect must reclaim this identity instead of minting one, even if a + // teardown call below throws unexpectedly. + detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.abortController?.abort() + this.stopPortScanning() + this.broadcastEmptyLists() + // Why: disconnect keeps PTY ownership so a later manual connect can reattach. + this.teardownProviders('connection_lost') + this.currentConnection = null + this._state = 'disposed' + this.detachedInMemory = true + } + this.detachFlushRejected = false + return settleSshSessionTeardown([ + this.store.markSshRemotePtyLeasesAsync(this.targetId, 'detached') + ]).catch((error: unknown) => { + this.detachFlushRejected = true + throw error + }) } // ── Private ─────────────────────────────────────────────────────── @@ -832,7 +980,8 @@ export class SshRelaySession { private async openPtyConsumerSession( mux: SshChannelMultiplexer, - serverBuildId: string | undefined + serverBuildId: string | undefined, + ownsAttempt: () => boolean ): Promise { const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) const options = { @@ -876,18 +1025,25 @@ export class SshRelaySession { ) } } - removeSshPtyConsumerOwnerRecovery(this.targetId, this.ptyConsumerClientInstanceId, this.store) + await removeSshPtyConsumerOwnerRecovery( + this.targetId, + this.ptyConsumerClientInstanceId, + this.store + ) + if (!ownsAttempt()) { + throw new Error('Session disposed during establish') + } this.ptyConsumerSessionState = null return openSshPtyConsumerSession(mux, options) } } - private rememberPtyConsumerRecovery(serverBuildId: string | undefined): void { + private async rememberPtyConsumerRecovery(serverBuildId: string | undefined): Promise { const owner = this.activePtyConsumerOwner() if (!owner || !serverBuildId) { return } - rememberSshPtyConsumerRecovery({ + await rememberSshPtyConsumerRecovery({ targetId: this.targetId, clientInstanceId: this.ptyConsumerClientInstanceId, serverBuildId, @@ -1138,6 +1294,8 @@ export class SshRelaySession { isReplay?: unknown providerSession?: unknown providerSessionOnly?: unknown + shedFields?: unknown + claudeRunningNonAgentTask?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -1166,6 +1324,12 @@ export class SshRelaySession { isReplay: envelope.isReplay === true ? true : undefined, providerSession: envelope.providerSession, providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + // Why: names the fields the relay dropped to fit the frame; ingestRemote restores them. + shedFields: envelope.shedFields, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, payload: envelope.payload }, this.targetId @@ -1699,6 +1863,7 @@ export class SshRelaySession { }) .filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null) ) + const attachedLeaseIds = new Set() // Why: after app restart ptyOwnership is empty, but durable SSH leases still describe grace-window survivors. const ptyIds = Array.from( new Set([ @@ -1726,6 +1891,7 @@ export class SshRelaySession { ptyId, activeLeaseByPtyId, expectedIdentityByPtyId, + attachedLeaseIds, mux, providerGeneration, shouldContinue @@ -1745,6 +1911,12 @@ export class SshRelaySession { await Promise.all( Array.from({ length: Math.min(SSH_PTY_REATTACH_MAX_CONCURRENCY, ptyIds.length) }, worker) ) + if (attachedLeaseIds.size > 0 && shouldContinue()) { + await this.store.markSshRemotePtyLeasesAttachedAsync( + this.targetId, + Array.from(attachedLeaseIds) + ) + } } private async reattachKnownPty(args: { @@ -1752,6 +1924,7 @@ export class SshRelaySession { ptyId: string activeLeaseByPtyId: Map expectedIdentityByPtyId: Map + attachedLeaseIds: Set mux: SshChannelMultiplexer providerGeneration: number shouldContinue: () => boolean @@ -1761,6 +1934,7 @@ export class SshRelaySession { ptyId, activeLeaseByPtyId, expectedIdentityByPtyId, + attachedLeaseIds, mux, providerGeneration, shouldContinue @@ -1876,7 +2050,7 @@ export class SshRelaySession { activeLeaseByPtyId.get(ptyId) ) } - this.store.markSshRemotePtyLease(this.targetId, ptyId, 'attached') + attachedLeaseIds.add(ptyId) pendingReattach.activated = true recoveryActivationLease?.commit() recoveryActivationLease = undefined diff --git a/src/main/ssh/ssh-security-key-identity.test.ts b/src/main/ssh/ssh-security-key-identity.test.ts index c424bfa6891..a16f5d2d96d 100644 --- a/src/main/ssh/ssh-security-key-identity.test.ts +++ b/src/main/ssh/ssh-security-key-identity.test.ts @@ -53,6 +53,29 @@ async function writeKey(contents: Buffer, filename = 'security key'): Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-default-key-home-')) + tempDirs.push(directory) + await mkdir(join(directory, '.ssh')) + for (const [name, contents] of Object.entries(files)) { + await writeFile(join(directory, '.ssh', name), contents) + } + return directory +} + +// Why: `ssh -G` echoes this list, already home-expanded, for every host — configured or not. +function listBuiltInDefaultIdentityFiles(home: string): string[] { + return [ + 'id_rsa', + 'id_ecdsa', + 'id_ecdsa_sk', + 'id_ed25519', + 'id_ed25519_sk', + 'id_xmss', + 'id_dsa' + ].map((name) => join(home, '.ssh', name)) +} + afterEach(async () => { vi.unstubAllEnvs() await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true }))) @@ -155,13 +178,9 @@ describe('isOpenSshSecurityKeyPrivateKey', () => { describe('requiresSystemSshForSecurityKey', () => { it('uses default FIDO2 identities only when config resolution is unavailable', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-')) - tempDirs.push(directory) - await mkdir(join(directory, '.ssh')) - await writeFile( - join(directory, '.ssh', 'id_ed25519_sk'), - createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) - ) + const directory = await createDefaultKeyHome({ + id_ed25519_sk: createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + }) vi.stubEnv('ORCA_TEST_SSH_HOME', directory) await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) @@ -170,28 +189,58 @@ describe('requiresSystemSshForSecurityKey', () => { ).resolves.toBe(false) }) - it('keeps a regular default ahead of a dormant FIDO2 identity', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-')) - tempDirs.push(directory) - await mkdir(join(directory, '.ssh')) - await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa'])) - await writeFile( - join(directory, '.ssh', 'id_ed25519_sk'), - createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) - ) + it('reaches a default FIDO2 identity that a regular default key precedes', async () => { + const directory = await createDefaultKeyHome({ + id_rsa: createOpenSshPrivateKeyFixture(['ssh-rsa']), + id_ed25519_sk: createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + }) + vi.stubEnv('ORCA_TEST_SSH_HOME', directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) + + findSystemSshMock.mockReturnValue(null) + await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) + }) + + it('leaves regular-only defaults on ssh2', async () => { + const directory = await createDefaultKeyHome({ + id_rsa: createOpenSshPrivateKeyFixture(['ssh-rsa']) + }) vi.stubEnv('ORCA_TEST_SSH_HOME', directory) await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false) }) - it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-')) - tempDirs.push(directory) - await mkdir(join(directory, '.ssh')) - await writeFile( - join(directory, '.ssh', 'id_ed25519_sk'), - createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + it('treats resolved built-in default identities as unconfigured, not as forced transport', async () => { + const directory = await createDefaultKeyHome({ + id_rsa: createOpenSshPrivateKeyFixture(['ssh-rsa']), + id_ed25519_sk: createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + }) + const identityFile = listBuiltInDefaultIdentityFiles(directory) + + await expect(requiresSystemSshForSecurityKey(createTarget(), { identityFile })).resolves.toBe( + true ) + + findSystemSshMock.mockReturnValue(null) + await expect(requiresSystemSshForSecurityKey(createTarget(), { identityFile })).resolves.toBe( + false + ) + }) + + it('keeps password and agent fallback when a configured FIDO2 identity has no OpenSSH', async () => { + const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])) + findSystemSshMock.mockReturnValue(null) + + await expect( + requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null) + ).resolves.toBe(false) + }) + + it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => { + const directory = await createDefaultKeyHome({ + id_ed25519_sk: createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + }) vi.stubEnv('ORCA_TEST_SSH_HOME', directory) findSystemSshMock.mockReturnValue(null) @@ -199,17 +248,10 @@ describe('requiresSystemSshForSecurityKey', () => { }) it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-')) - tempDirs.push(directory) - await mkdir(join(directory, '.ssh')) - await writeFile( - join(directory, '.ssh', 'id_ed25519.pub'), - createOpenSshPublicKeyFixture('ssh-ed25519') - ) - await writeFile( - join(directory, '.ssh', 'id_ed25519_sk'), - createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) - ) + const directory = await createDefaultKeyHome({ + 'id_ed25519.pub': createOpenSshPublicKeyFixture('ssh-ed25519'), + id_ed25519_sk: createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]) + }) vi.stubEnv('ORCA_TEST_SSH_HOME', directory) await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true) diff --git a/src/main/ssh/ssh-transport-selection.ts b/src/main/ssh/ssh-transport-selection.ts index fdbfb87075f..2c058fcac85 100644 --- a/src/main/ssh/ssh-transport-selection.ts +++ b/src/main/ssh/ssh-transport-selection.ts @@ -21,8 +21,6 @@ const READ_CHUNK_BYTES = 64 * 1024 const READ_OPEN_FLAGS = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK) -type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean } - async function readBoundedKeyFile(path: string): Promise { let handle: Awaited> | undefined try { @@ -59,21 +57,15 @@ async function readBoundedKeyFile(path: string): Promise { } } -async function inspectIdentityPath(keyPath: string): Promise { +async function identityRequiresSystemSsh(keyPath: string): Promise { const resolvedPath = resolveSshConfigHomePath(keyPath) const identity = await readBoundedKeyFile(resolvedPath) + // Why: a present private key wins; a `.pub` beside it may describe a key already replaced. if (identity !== null) { - return { - privateIdentityExists: true, - requiresSystemSsh: - isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity) - } + return isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity) } const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`) - return { - privateIdentityExists: false, - requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity) - } + return publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity) } export function shouldUseSystemSshTransport( @@ -102,16 +94,19 @@ export async function requiresSystemSshForSecurityKey( target: SshTarget, resolved: Pick | null ): Promise { - const configuredPaths = resolveIdentityFilePaths(target, resolved) - const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile - const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths + const resolvedPaths = resolveIdentityFilePaths(target, resolved) + // Why: `ssh -G` already echoes OpenSSH's built-in defaults, so its list is the real candidate + // set; guess at the defaults only when config resolution failed outright. + const identityPaths = + resolvedPaths.length === 0 && !resolved && !target.identityFile + ? listDefaultIdentityFilePaths() + : resolvedPaths for (const keyPath of identityPaths) { - const inspection = await inspectIdentityPath(keyPath) - if (inspection.requiresSystemSsh) { - return !usesDefaultPaths || findSystemSsh() !== null - } - if (usesDefaultPaths && inspection.privateIdentityExists) { - return false + if (await identityRequiresSystemSsh(keyPath)) { + // Why: scan every candidate — an earlier normal key never rules out a security key the host + // requires — but forcing system transport with no binary hard-fails a connection ssh2 could + // still have served over agent or password auth. + return findSystemSsh() !== null } } return false diff --git a/src/main/stats/collector-async-save.test.ts b/src/main/stats/collector-async-save.test.ts index 0fc34072b47..80d6c8b5497 100644 --- a/src/main/stats/collector-async-save.test.ts +++ b/src/main/stats/collector-async-save.test.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Why: the debounced stats save must be async (off the main thread), while the -// shutdown flush() must stay synchronous. These tests pin both behaviors, prove -// the async path leaves no stray temp files, and — critically — prove an -// in-flight async write can never clobber the more-complete shutdown flush. +// Why: the debounced stats save must be async (off the main thread), while flush() +// stays synchronous for callers that cannot await. These tests pin both behaviors, +// prove the async path leaves no stray temp files, and — critically — prove an +// in-flight async write can never clobber the more-complete flush, at either of the +// two points it can park: before its temp write, and after, on the rename itself. let userDataDir: string const statsPath = (): string => join(userDataDir, 'orca-stats.json') @@ -20,8 +21,13 @@ vi.mock('electron', () => ({ // debounced async write "in flight" while it drives a synchronous shutdown flush. const gate = vi.hoisted(() => ({ blocked: false, + blockRename: false, waiters: [] as (() => void)[], - writeFileCalls: 0 + renameWaiters: [] as (() => void)[], + writeFileCalls: 0, + renameCalls: 0, + failNextWrite: false, + failNextRenameEnoent: false })) vi.mock('node:fs/promises', async () => { @@ -31,9 +37,24 @@ vi.mock('node:fs/promises', async () => { if (gate.blocked) { await new Promise((resolve) => gate.waiters.push(resolve)) } + if (gate.failNextWrite) { + gate.failNextWrite = false + throw new Error('transient write failure') + } return actual.writeFile(...args) }) as typeof actual.writeFile - return { ...actual, writeFile } + const rename = (async (...args: Parameters) => { + gate.renameCalls += 1 + if (gate.failNextRenameEnoent) { + gate.failNextRenameEnoent = false + throw Object.assign(new Error('missing temp'), { code: 'ENOENT' }) + } + if (gate.blockRename) { + await new Promise((resolve) => gate.renameWaiters.push(resolve)) + } + return actual.rename(...args) + }) as typeof actual.rename + return { ...actual, writeFile, rename } }) async function importCollector() { @@ -44,8 +65,13 @@ describe('StatsCollector async debounced save', () => { beforeEach(() => { userDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-test-')) gate.blocked = false + gate.blockRename = false gate.waiters = [] + gate.renameWaiters = [] gate.writeFileCalls = 0 + gate.renameCalls = 0 + gate.failNextWrite = false + gate.failNextRenameEnoent = false vi.resetModules() }) @@ -78,6 +104,70 @@ describe('StatsCollector async debounced save', () => { expect(Array.isArray(parsed.events)).toBe(true) }) + it('coalesces timer saves while one write is stalled', async () => { + vi.useFakeTimers() + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + + gate.blocked = true + collector.onAgentStart('pty-1', 1_000) + await vi.advanceTimersByTimeAsync(5_000) + await vi.waitFor(() => expect(gate.writeFileCalls).toBe(1)) + + for (let index = 2; index <= 5; index += 1) { + collector.onAgentStart(`pty-${index}`, index * 1_000) + await vi.advanceTimersByTimeAsync(5_000) + } + expect(gate.writeFileCalls).toBe(1) + + gate.blocked = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await vi.waitFor(() => expect(gate.writeFileCalls).toBe(2)) + await ( + collector as unknown as { snapshotWriter: { waitForPendingWrite(): Promise } } + ).snapshotWriter.waitForPendingWrite() + + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentsSpawned).toBe(5) + }) + + it('retries a queued final snapshot after the active write fails', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + const internals = collector as unknown as { enqueueWrite(): Promise } + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + + collector.onAgentStart('pty-final', 1_000) + gate.blocked = true + void internals.enqueueWrite() + await vi.waitFor(() => expect(gate.writeFileCalls).toBe(1)) + const finalWrite = collector.flushAsync() + + gate.failNextWrite = true + gate.blocked = false + gate.waiters.splice(0).forEach((resolve) => resolve()) + await finalWrite + errors.mockRestore() + + expect(gate.writeFileCalls).toBe(2) + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1) + }) + + it('does not treat a genuine rename ENOENT as a committed snapshot', async () => { + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + const internals = collector as unknown as { enqueueWrite(): Promise } + collector.onAgentStart('pty-enoent', 1_000) + + gate.failNextRenameEnoent = true + await expect(internals.enqueueWrite()).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(internals.enqueueWrite()).resolves.toBeUndefined() + + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1) + }) + it('flush() writes synchronously (no timer, immediately on disk)', async () => { const { StatsCollector, initStatsPath } = await importCollector() initStatsPath() @@ -96,17 +186,17 @@ describe('StatsCollector async debounced save', () => { initStatsPath() const collector = new StatsCollector() // White-box: drive the two writers directly so the race is deterministic - // (the debounce path calls writeToDiskAsync; flush() calls writeToDiskSync). - const internals = collector as unknown as { writeToDiskAsync: () => Promise } + const internals = collector as unknown as { enqueueWrite: () => Promise } collector.onAgentStart('pty-a', 1_000) collector.onAgentStart('pty-b', 1_000) // Start the async write; it parks inside the blocked writeFile, before rename. + // Why waitFor and not one tick: the writer awaits mkdir first, so the number of + // microtask turns before writeFile is reached is an implementation detail. gate.blocked = true - const inflight = internals.writeToDiskAsync() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(gate.writeFileCalls).toBeGreaterThan(0) + const inflight = internals.enqueueWrite() + await vi.waitFor(() => expect(gate.writeFileCalls).toBeGreaterThan(0)) // App quits: close out both agents and flush the COMPLETE state synchronously // (newer generation) while the async write is still parked. @@ -124,4 +214,33 @@ describe('StatsCollector async debounced save', () => { expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) }) + + it('an async write already parked on its rename cannot clobber a shutdown flush', async () => { + // The generation guard alone stopped covering this once the swap became async: a writer + // parked on `await rename` has already cleared the guard, so only removing the temp file + // it would rename keeps the flush's fuller state from being overwritten. + const { StatsCollector, initStatsPath } = await importCollector() + initStatsPath() + const collector = new StatsCollector() + const internals = collector as unknown as { enqueueWrite: () => Promise } + + collector.onAgentStart('pty-a', 1_000) + collector.onAgentStart('pty-b', 1_000) + + gate.blockRename = true + const inflight = internals.enqueueWrite() + await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0)) + + collector.onAgentStop('pty-a', 5_000) + collector.onAgentStop('pty-b', 5_000) + collector.flush() + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + + gate.blockRename = false + gate.renameWaiters.splice(0).forEach((resolve) => resolve()) + await expect(inflight).resolves.toBeUndefined() + + expect(JSON.parse(readFileSync(statsPath(), 'utf-8')).aggregates.totalAgentTimeMs).toBe(8_000) + expect(readdirSync(userDataDir).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) + }) }) diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 508aa0a172a..ccc516f95f4 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,11 +1,10 @@ import { app } from 'electron' -import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' -import { writeFile, mkdir, rm } from 'node:fs/promises' -import { join, dirname } from 'node:path' +import { join } from 'node:path' import type { StatsSummary } from '../../shared/types' -import type { StatsEvent, StatsAggregates, StatsFile } from './types' +import type { StatsEvent, StatsAggregates } from './types' +import { loadStatsFile, STATS_SCHEMA_VERSION } from './stats-file-loader' +import { StatsSnapshotWriter } from './stats-snapshot-writer' -const STATS_SCHEMA_VERSION = 1 const MAX_EVENTS = 10_000 // Why: countedPRs is a deduplication registry that grows with every PR created // through Orca. Without a cap, a heavily-used instance accumulates thousands of @@ -33,40 +32,22 @@ function getStatsFile(): string { return _statsFile } -function getDefaultAggregates(): StatsAggregates { - return { - totalAgentsSpawned: 0, - totalPRsCreated: 0, - totalAgentTimeMs: 0, - countedPRs: [], - firstEventAt: null - } -} - -function getDefaultStatsFile(): StatsFile { - return { - schemaVersion: STATS_SCHEMA_VERSION, - events: [], - aggregates: getDefaultAggregates() - } -} - export class StatsCollector { private events: StatsEvent[] private aggregates: StatsAggregates private liveAgents = new Map() // ptyId → startTimestamp private writeTimer: ReturnType | null = null - // Monotonic id stamped on each prepared payload; the highest committed one - // wins so a slow in-flight async write can't clobber a newer sync flush. - private writeGeneration = 0 - private lastCommittedGeneration = 0 + private readonly snapshotWriter = new StatsSnapshotWriter(getStatsFile) + /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ + private quitFlushStarted = false + private quitFlushPromise: Promise | null = null // Why: star-nag lives in its own service but needs to observe the running // agent-spawned counter. A lightweight listener avoids cyclic imports and // keeps StatsCollector unaware of how the counter is consumed. private agentStartListeners: ((totalAgentsSpawned: number) => void)[] = [] constructor() { - const data = this.load() + const data = loadStatsFile(getStatsFile()) this.events = data.events this.aggregates = data.aggregates } @@ -146,6 +127,41 @@ export class StatsCollector { * a second flush() after resumed activity works correctly. */ flush(): void { + if (this.quitFlushStarted) { + return + } + this.closeOutLiveAgents() + this.cancelPendingSave() + this.snapshotWriter.writeSync(() => this.serialize()) + } + + /** + * Async twin of flush() for the quit path. + * + * Why the quit path needs one: writeToDiskSync parks the Electron main thread for the + * whole ~900KB write, and on a stalled network profile mount that park is uninterruptible + * — the app stops repainting and stops responding to Force Quit. + * + * Why the agent closeout stays synchronous: will-quit calls this before killAllPty(), + * which skips runtime.onPtyExit(), so live agents must be stopped before the kill even + * though the write itself is awaited later. + * + * Never throws — it joins the quit teardown barrier, where a rejection is noise. + */ + flushAsync(): Promise { + if (this.quitFlushPromise) { + return this.quitFlushPromise + } + this.quitFlushStarted = true + this.closeOutLiveAgents() + this.cancelPendingSave() + this.quitFlushPromise = this.enqueueWrite().catch((err) => { + console.error('[stats] Failed to flush stats:', err) + }) + return this.quitFlushPromise + } + + private closeOutLiveAgents(): void { const now = Date.now() // Why snapshot keys: onAgentStop mutates liveAgents, so we snapshot // the keys first to avoid iterator invalidation. @@ -153,38 +169,10 @@ export class StatsCollector { for (const ptyId of livePtyIds) { this.onAgentStop(ptyId, now) } - this.cancelPendingSave() - this.writeToDiskSync() } // ── Persistence ─────────────────────────────────────────────────── - private load(): StatsFile { - try { - const statsFile = getStatsFile() - if (existsSync(statsFile)) { - const raw = readFileSync(statsFile, 'utf-8') - const parsed = JSON.parse(raw) as StatsFile - // Merge with defaults for forward compatibility - return { - ...getDefaultStatsFile(), - ...parsed, - aggregates: { - ...getDefaultAggregates(), - ...parsed.aggregates - } - } - } - } catch (err) { - // Why "start fresh" instead of crashing: lifetime aggregates are lost - // on corruption, which is unfortunate but not critical — this is a - // "fun stats" feature, not billing data. The corrupt file is left on - // disk so it can be inspected for debugging. - console.error('[stats] Failed to load stats, starting fresh:', err) - } - return getDefaultStatsFile() - } - private updateAggregates(event: StatsEvent): void { if (this.aggregates.firstEventAt === null) { this.aggregates.firstEventAt = event.at @@ -226,18 +214,18 @@ export class StatsCollector { } private scheduleSave(): void { + // Why: once the quit flush has run, a newly debounced write would fire during teardown + // with nothing awaiting it, and the process can exit mid-rename. + if (this.quitFlushStarted) { + return + } if (this.writeTimer) { return // already scheduled } this.writeTimer = setTimeout(() => { this.writeTimer = null - // Why: the debounced save is fun-stats telemetry, not crash-critical - // state, so it uses the async writer to move the ~900KB tmp-file write - // off the main thread (the stringify stays sync — see prepareWritePayload). - // A chatty multi-agent session re-arms this every 5s; a fully-sync write - // is a recurring main-thread stall. Shutdown flush() stays synchronous; - // the generation guard keeps the two paths race-safe. - void this.writeToDiskAsync().catch((err) => { + // Why async: a chatty session can write ~900KB every 5s and stall the main thread. + void this.enqueueWrite().catch((err) => { console.error('[stats] Failed to write stats:', err) }) }, DEBOUNCE_MS) @@ -250,68 +238,18 @@ export class StatsCollector { } } - // Serialize the current state and pick a unique temp path. Trimming mutates - // in-memory state and JSON.stringify must see a consistent snapshot, so both - // writers call this synchronously before any await to avoid a torn snapshot. - // The monotonic generation lets a later write veto an earlier, still-in-flight - // one so a stale rename can never win (see writeToDiskAsync). - private prepareWritePayload(): { - statsFile: string - tmpFile: string - json: string - generation: number - } { - const statsFile = getStatsFile() - - // Trim events to bounded size before writing + private serialize(): string { if (this.events.length > MAX_EVENTS) { this.events = this.events.slice(-MAX_EVENTS) } - - const data: StatsFile = { + return JSON.stringify({ schemaVersion: STATS_SCHEMA_VERSION, events: this.events, aggregates: this.aggregates - } - - const generation = ++this.writeGeneration - // Unique temp file so the async debounced writer and the sync shutdown - // flush never write the same temp path (same pattern as persistence.ts). - const tmpFile = `${statsFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - return { statsFile, tmpFile, json: JSON.stringify(data), generation } + }) } - private writeToDiskSync(): void { - const dir = dirname(getStatsFile()) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() - writeFileSync(tmpFile, json, 'utf-8') - renameSync(tmpFile, statsFile) - this.lastCommittedGeneration = Math.max(this.lastCommittedGeneration, generation) - } - - private async writeToDiskAsync(): Promise { - const dir = dirname(getStatsFile()) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }) - } - const { statsFile, tmpFile, json, generation } = this.prepareWritePayload() - // Only the ~900KB tmp write moves off the main thread; stringify stayed sync - // above (torn-snapshot constraint). The rename is a trivial metadata op done - // SYNCHRONOUSLY so it stays ordered with the shutdown flush's renameSync — - // an async rename could land after flush and clobber the more-complete - // shutdown data. The generation guard vetoes this write if a newer one (a - // later debounce OR the shutdown flush) already committed while we were - // writing; the check + rename run with no await between them, so the sync - // flush cannot interleave. - await writeFile(tmpFile, json, 'utf-8') - if (this.lastCommittedGeneration >= generation) { - await rm(tmpFile, { force: true }).catch(() => {}) - return - } - renameSync(tmpFile, statsFile) - this.lastCommittedGeneration = generation + private enqueueWrite(): Promise { + return this.snapshotWriter.write(() => this.serialize()) } } diff --git a/src/main/stats/stats-file-loader.ts b/src/main/stats/stats-file-loader.ts new file mode 100644 index 00000000000..194a8a3275a --- /dev/null +++ b/src/main/stats/stats-file-loader.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { StatsAggregates, StatsFile } from './types' + +export const STATS_SCHEMA_VERSION = 1 + +function defaultAggregates(): StatsAggregates { + return { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + countedPRs: [], + firstEventAt: null + } +} + +function defaultStatsFile(): StatsFile { + return { + schemaVersion: STATS_SCHEMA_VERSION, + events: [], + aggregates: defaultAggregates() + } +} + +export function loadStatsFile(statsFile: string): StatsFile { + try { + if (existsSync(statsFile)) { + const parsed = JSON.parse(readFileSync(statsFile, 'utf-8')) as StatsFile + return { + ...defaultStatsFile(), + ...parsed, + aggregates: { ...defaultAggregates(), ...parsed.aggregates } + } + } + } catch (err) { + console.error('[stats] Failed to load stats, starting fresh:', err) + } + return defaultStatsFile() +} diff --git a/src/main/stats/stats-snapshot-writer.ts b/src/main/stats/stats-snapshot-writer.ts new file mode 100644 index 00000000000..f118a7ec5af --- /dev/null +++ b/src/main/stats/stats-snapshot-writer.ts @@ -0,0 +1,137 @@ +import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { durableWriteTempPath, removeStaleDurableWriteTempFiles } from '../durable-file-write' + +const STALE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 + +export class StatsSnapshotWriter { + private writeGeneration = 0 + private lastCommittedGeneration = 0 + private writeRequested = false + private pendingWrite: Promise | null = null + private pendingSerialize: (() => string) | null = null + private readonly staleTempCleanup: Promise + private inFlightAsyncTmpFile: string | null = null + + constructor(private readonly resolveFile: () => string) { + this.staleTempCleanup = removeStaleDurableWriteTempFiles(resolveFile(), { + minimumAgeMs: STALE_WRITE_TEMP_AGE_MS + }) + } + + write(serialize: () => string): Promise { + this.writeRequested = true + this.pendingSerialize = serialize + if (this.pendingWrite) { + return this.pendingWrite + } + const run = this.drainWrites() + const tracked = run.finally(() => { + if (this.pendingWrite === tracked) { + this.pendingWrite = null + } + }) + this.pendingWrite = tracked + return tracked + } + + writeSync(serialize: () => string): void { + const statsFile = this.resolveFile() + const dir = dirname(statsFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + if (this.inFlightAsyncTmpFile) { + try { + unlinkSync(this.inFlightAsyncTmpFile) + this.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void this.write(serialize).catch((writeError) => { + console.error('[stats] Failed to write stats:', writeError) + }) + throw error + } + } + } + const { tmpFile, json, generation } = this.preparePayload(statsFile, serialize) + writeFileSync(tmpFile, json, 'utf-8') + renameSync(tmpFile, statsFile) + this.lastCommittedGeneration = Math.max(this.lastCommittedGeneration, generation) + } + + waitForPendingWrite(): Promise { + return this.pendingWrite ?? Promise.resolve() + } + + private preparePayload( + finalPath: string, + serialize: () => string + ): { + tmpFile: string + json: string + generation: number + } { + const generation = ++this.writeGeneration + return { + tmpFile: durableWriteTempPath(finalPath), + json: serialize(), + generation + } + } + + private async drainWrites(): Promise { + await this.staleTempCleanup + let firstError: unknown = null + while (this.writeRequested) { + this.writeRequested = false + const serialize = this.pendingSerialize! + try { + await this.writeToDiskAsync(serialize) + } catch (error) { + firstError ??= error + if (!this.writeRequested) { + throw error + } + } + } + if (firstError) { + throw firstError + } + } + + private async writeToDiskAsync(serialize: () => string): Promise { + const statsFile = this.resolveFile() + await mkdir(dirname(statsFile), { recursive: true }).catch(() => {}) + const { tmpFile, json, generation } = this.preparePayload(statsFile, serialize) + let renamed = false + try { + await writeFile(tmpFile, json, 'utf-8') + if (this.lastCommittedGeneration >= generation) { + return + } + this.inFlightAsyncTmpFile = tmpFile + try { + await rename(tmpFile, statsFile) + renamed = true + this.lastCommittedGeneration = Math.max(this.lastCommittedGeneration, generation) + } catch (err) { + if ( + (err as NodeJS.ErrnoException).code !== 'ENOENT' || + this.lastCommittedGeneration < generation + ) { + throw err + } + } finally { + if (this.inFlightAsyncTmpFile === tmpFile) { + this.inFlightAsyncTmpFile = null + } + } + } finally { + if (!renamed) { + await rm(tmpFile, { force: true }).catch(() => {}) + } + } + } +} diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index 15ff9c28bde..20a8764548c 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -31,10 +31,6 @@ export async function listSystemFontFamilies(): Promise { return fontsPromise } -export function warmSystemFontFamilies(): void { - void listSystemFontFamilies() -} - function loadSystemFontFamilies(): Promise { if (process.platform === 'darwin') { return listMacFonts() diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts index d1f2b2fa057..93bddaaab81 100644 --- a/src/main/telemetry/client.ts +++ b/src/main/telemetry/client.ts @@ -329,10 +329,6 @@ export function _setShuttingDownForTests(value: boolean): void { shuttingDown = value } -export function _getSessionIdForTests(): string | null { - return sessionId -} - export function _enableTransportForTests(enabled: boolean): void { testTransportEnabled = enabled } diff --git a/src/main/terminal-scrollback-snapshot-async-migration.ts b/src/main/terminal-scrollback-snapshot-async-migration.ts new file mode 100644 index 00000000000..b1a064657c1 --- /dev/null +++ b/src/main/terminal-scrollback-snapshot-async-migration.ts @@ -0,0 +1,54 @@ +import type { WorkspaceSessionState } from '../shared/types' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshot, + type TerminalScrollbackSnapshotStorage, + writeTerminalScrollbackSnapshot +} from './terminal-scrollback-snapshots' + +export async function migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + session: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): Promise { + let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const buffers = layout.buffersByLeafId + if (!buffers || Object.keys(buffers).length === 0) { + continue + } + const refs = { ...layout.scrollbackRefsByLeafId } + const remainingBuffers: Record = {} + for (const [leafId, buffer] of Object.entries(buffers)) { + const ref = await writeTerminalScrollbackSnapshot({ tabId, leafId, buffer, storage }) + if (ref) { + refs[leafId] = ref + } else { + remainingBuffers[leafId] = buffer + delete refs[leafId] + } + } + terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId } + terminalLayoutsByTabId[tabId] = { + ...layout, + buffersByLeafId: Object.keys(remainingBuffers).length > 0 ? remainingBuffers : undefined, + scrollbackRefsByLeafId: Object.keys(refs).length > 0 ? refs : undefined + } + } + return terminalLayoutsByTabId ? { ...session, terminalLayoutsByTabId } : session +} + +export async function deleteRemovedTerminalScrollbackSnapshotsAsync( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): Promise { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + await Promise.all( + [...collectTerminalScrollbackSnapshotRefs(prior)] + .filter((ref) => !nextRefs.has(ref)) + .map((ref) => deleteTerminalScrollbackSnapshot(ref, storage)) + ) +} diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 3929288d588..de387c356c9 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -9,6 +9,7 @@ import { statSync, writeFileSync } from 'node:fs' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { app } from 'electron' import type { WorkspaceSessionState } from '../shared/types' @@ -133,6 +134,42 @@ export function writeTerminalScrollbackSnapshotSync(args: { } } +export async function writeTerminalScrollbackSnapshot(args: { + tabId: string + leafId: string + buffer: string + storage?: TerminalScrollbackSnapshotStorage +}): Promise { + if (!args.buffer) { + return null + } + const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) + const snapshotRoot = getSnapshotRoot(args.storage) + const path = snapshotPath(ref, snapshotRoot) + if (!path) { + return null + } + const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + let renamed = false + try { + await mkdir(snapshotRoot, { recursive: true, mode: 0o700 }) + const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) + await writeFile(tmpPath, bytes, { mode: 0o600 }) + await rename(tmpPath, path) + renamed = true + return ref + } catch (err) { + console.warn( + `[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}` + ) + return null + } finally { + if (!renamed) { + await rm(tmpPath, { force: true }).catch(() => {}) + } + } +} + export function readTerminalScrollbackSnapshotSync( ref: string, storage?: TerminalScrollbackSnapshotStorage @@ -160,6 +197,15 @@ export function deleteTerminalScrollbackSnapshotSync( } } +export async function deleteTerminalScrollbackSnapshot( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): Promise { + await Promise.all( + snapshotReadPaths(ref, storage).map((path) => rm(path, { force: true }).catch(() => {})) + ) +} + export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionState): Set { const refs = new Set() for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { diff --git a/src/main/updater.ts b/src/main/updater.ts index 1551720b72f..d5b5065afc9 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import { app, BrowserWindow, powerMonitor } from 'electron' import { is } from '@electron-toolkit/utils' -import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' +import type { UpdateCheckOptions, UpdateSource, UpdateStatus } from '../shared/types' import type { RemoteServerUpdateInstallResult, RemoteServerUpdaterSnapshot, @@ -46,6 +46,7 @@ import { import type { LocalBuildFeed } from './local-builds/local-build-feed-server' import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' import { + hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, type ReleaseBuild, type ReleaseChannel @@ -146,7 +147,7 @@ let downloadInFlight = false /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | 'local' | 'hourly' = 'release' +let activeUpdateSource: 'release' | UpdateSource = 'release' let activeLocalBuildFeed: LocalBuildFeed | null = null let localBuildSelectionInProgress = false // Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade @@ -336,8 +337,8 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant return 'prerelease' } // Why: a persisted 'rc' override makes every routine check follow the RC series - // without the user re-holding shift; 'hourly' needs an explicit tag, so it is - // not a routine-check variant. + // without the user re-holding shift; the dev channels need an explicit tag, so + // neither is a routine-check variant. if (getReleaseChannelOverride?.() === 'rc') { return 'prerelease' } @@ -1533,7 +1534,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis if (!isChannelSupportedOnPlatform(channel, process.platform)) { sendStatus({ state: 'error', - message: 'Hourly builds are produced only for macOS.', + message: `${channel} builds are produced only for macOS.`, userInitiated: true }) return @@ -1552,7 +1553,7 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis return } closeLocalBuildFeed() - activeUpdateSource = channel === 'hourly' ? 'hourly' : 'release' + activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' isPinnedBuildActive = true clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() diff --git a/src/main/usage/usage-event-aggregation.ts b/src/main/usage/usage-event-aggregation.ts new file mode 100644 index 00000000000..ca3eee17544 --- /dev/null +++ b/src/main/usage/usage-event-aggregation.ts @@ -0,0 +1,269 @@ +/** + * Folds attributed usage events into per-session and per-day rollups. Shared by every + * event-based usage provider so a token-accounting fix lands in all of them at once. + */ +import { mergeUsageDailyAggregates, mergeUsageSessions } from './usage-rollup-merge' +import { + usageDailyAggregateKey, + type UsageAttributedEventFields, + type UsageDailyAggregate, + type UsageLocationBreakdown, + type UsageLocationModelBreakdown, + type UsageMetricFold, + type UsageModelBreakdown, + type UsageSession +} from './usage-rollup-records' + +export type UsageEventAggregationOptions = { + metric: UsageMetricFold + /** Codex and OpenCode clone differently today; keep each provider's strategy explicit. */ + cloneSessionForMerge(session: UsageSession): UsageSession +} + +export function createUsageEventAggregation< + TEvent extends UsageAttributedEventFields, + TMetric extends object +>(options: UsageEventAggregationOptions) { + const { metric, cloneSessionForMerge } = options + + function createEmptySession(event: TEvent): UsageSession { + return { + sessionId: event.sessionId, + firstTimestamp: event.timestamp, + lastTimestamp: event.timestamp, + primaryModel: event.model, + hasMixedModels: false, + primaryProjectLabel: event.projectLabel, + hasMixedLocations: false, + primaryWorktreeId: event.worktreeId, + primaryRepoId: event.repoId, + eventCount: 0, + totalInputTokens: 0, + totalCachedInputTokens: 0, + totalOutputTokens: 0, + totalReasoningOutputTokens: 0, + totalTokens: 0, + ...metric.empty(), + locationBreakdown: [], + modelBreakdown: [], + locationModelBreakdown: [] + } + } + + function createEmptyDailyAggregate(event: TEvent): UsageDailyAggregate { + return { + day: event.day, + model: event.model, + projectKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + ...metric.empty() + } + } + + function foldLocation( + target: UsageLocationBreakdown[], + event: TEvent, + eventMetric: TMetric + ): void { + const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + metric.fold(existing, eventMetric) + return + } + + target.push({ + locationKey: event.projectKey, + projectLabel: event.projectLabel, + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + ...eventMetric + }) + } + + function foldModel( + target: UsageModelBreakdown[], + event: TEvent, + eventMetric: TMetric + ): void { + const key = event.model ?? 'unknown' + const existing = target.find((entry) => entry.modelKey === key) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + metric.fold(existing, eventMetric) + return + } + + target.push({ + modelKey: key, + modelLabel: event.model ?? 'Unknown model', + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + ...eventMetric + }) + } + + function foldLocationModel( + target: UsageLocationModelBreakdown[], + event: TEvent, + eventMetric: TMetric + ): void { + const modelKey = event.model ?? 'unknown' + const existing = + target.find( + (entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey + ) ?? null + if (existing) { + existing.eventCount++ + existing.inputTokens += event.inputTokens + existing.cachedInputTokens += event.cachedInputTokens + existing.outputTokens += event.outputTokens + existing.reasoningOutputTokens += event.reasoningOutputTokens + existing.totalTokens += event.totalTokens + metric.fold(existing, eventMetric) + return + } + + target.push({ + locationKey: event.projectKey, + modelKey, + modelLabel: event.model ?? 'Unknown model', + repoId: event.repoId, + worktreeId: event.worktreeId, + eventCount: 1, + inputTokens: event.inputTokens, + cachedInputTokens: event.cachedInputTokens, + outputTokens: event.outputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + totalTokens: event.totalTokens, + ...eventMetric + }) + } + + function finalizeSessions( + sessionsById: Map> + ): UsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens) + const primaryLocation = session.locationBreakdown[0] ?? null + const primaryModel = session.modelBreakdown[0] ?? null + session.primaryProjectLabel = + session.locationBreakdown.length <= 1 + ? (primaryLocation?.projectLabel ?? 'Unknown location') + : 'Multiple locations' + session.hasMixedLocations = session.locationBreakdown.length > 1 + session.primaryWorktreeId = primaryLocation?.worktreeId ?? null + session.primaryRepoId = primaryLocation?.repoId ?? null + session.primaryModel = + session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models' + session.hasMixedModels = session.modelBreakdown.length > 1 + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) + } + + function sortDailyAggregates( + dailyByKey: Map> + ): UsageDailyAggregate[] { + return [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) + } + + function aggregate(events: TEvent[]): { + sessions: UsageSession[] + dailyAggregates: UsageDailyAggregate[] + } { + const sessionsById = new Map>() + const dailyByKey = new Map>() + + for (const event of events) { + const eventMetric = metric.fromEvent(event) + const session = sessionsById.get(event.sessionId) ?? createEmptySession(event) + if (!sessionsById.has(event.sessionId)) { + sessionsById.set(event.sessionId, session) + } + if (event.timestamp < session.firstTimestamp) { + session.firstTimestamp = event.timestamp + } + if (event.timestamp >= session.lastTimestamp) { + session.lastTimestamp = event.timestamp + } + session.eventCount++ + session.totalInputTokens += event.inputTokens + session.totalCachedInputTokens += event.cachedInputTokens + session.totalOutputTokens += event.outputTokens + session.totalReasoningOutputTokens += event.reasoningOutputTokens + session.totalTokens += event.totalTokens + metric.fold(session, eventMetric) + foldLocation(session.locationBreakdown, event, eventMetric) + foldModel(session.modelBreakdown, event, eventMetric) + foldLocationModel(session.locationModelBreakdown, event, eventMetric) + + const dailyKey = usageDailyAggregateKey(event) + const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event) + if (!dailyByKey.has(dailyKey)) { + dailyByKey.set(dailyKey, daily) + } + daily.eventCount++ + daily.inputTokens += event.inputTokens + daily.cachedInputTokens += event.cachedInputTokens + daily.outputTokens += event.outputTokens + daily.reasoningOutputTokens += event.reasoningOutputTokens + daily.totalTokens += event.totalTokens + metric.fold(daily, eventMetric) + } + + return { + sessions: finalizeSessions(sessionsById), + dailyAggregates: sortDailyAggregates(dailyByKey) + } + } + + return { + aggregate, + finalizeSessions, + sortDailyAggregates, + mergeSessions: ( + target: Map>, + sessions: UsageSession[] + ): void => mergeUsageSessions(target, sessions, { fold: metric.fold, cloneSessionForMerge }), + mergeDailyAggregates: ( + target: Map>, + dailyAggregates: UsageDailyAggregate[] + ): void => mergeUsageDailyAggregates(target, dailyAggregates, metric.fold) + } +} diff --git a/src/main/usage/usage-path-comparison.ts b/src/main/usage/usage-path-comparison.ts new file mode 100644 index 00000000000..13591317277 --- /dev/null +++ b/src/main/usage/usage-path-comparison.ts @@ -0,0 +1,20 @@ +import { posix, win32 } from 'node:path' + +export function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +/** Key for grouping by location: slash/case differences must not fragment one folder into many. */ +export function normalizeComparablePath(pathValue: string, platform = process.platform): string { + const normalized = pathValue.replace(/\\/g, '/') + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? normalized.toLowerCase() + : normalized +} + +export function normalizeFsPath(pathValue: string, platform = process.platform): string { + if (platform === 'win32' || looksLikeWindowsPath(pathValue)) { + return win32.normalize(win32.resolve(pathValue)) + } + return posix.normalize(posix.resolve(pathValue)) +} diff --git a/src/main/usage/usage-provider-contract.ts b/src/main/usage/usage-provider-contract.ts new file mode 100644 index 00000000000..2937af8b05c --- /dev/null +++ b/src/main/usage/usage-provider-contract.ts @@ -0,0 +1,41 @@ +/** + * Seam a usage source implements to be scanned by Orca, including plugin-contributed ones. + * + * Deliberately generic over each provider's record types: Claude bills per turn while + * Codex/OpenCode bill per event, and `cachedInput` is a subset of `input` for the latter but a + * peer bucket for Claude. A single normalized record would push nullable handling onto every + * consumer, so only the scan envelope is shared. + */ + +export type UsageProviderId = 'claude' | 'codex' | 'opencode' | `plugin:${string}` + +/** Scan input. Distinct from `UsageWorktreeRef` in usage-worktree-metadata, which lacks `repoId`. */ +export type UsageScanWorktreeRef = { + repoId: string + worktreeId: string + path: string + displayName: string +} + +/** + * `TSourceKey` is the provider's own name for its per-source scan cache (`processedFiles`, + * `processedDatabases`). It stays a type parameter because those names are persisted on disk. + */ +export type UsageScanResult = Record< + TSourceKey, + readonly TSource[] +> & { + sessions: readonly TSession[] + dailyAggregates: readonly TDaily[] +} + +export type UsageProvider = { + readonly id: UsageProviderId + readonly label: string + /** Bumped when the persisted projection changes shape; older caches are discarded. */ + readonly schemaVersion: number + scan( + worktrees: UsageScanWorktreeRef[], + previous: TSource[] + ): Promise> +} diff --git a/src/main/usage/usage-record-coercion.ts b/src/main/usage/usage-record-coercion.ts new file mode 100644 index 00000000000..fe0700418d5 --- /dev/null +++ b/src/main/usage/usage-record-coercion.ts @@ -0,0 +1,11 @@ +export function ensureNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +export function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} diff --git a/src/main/usage/usage-rollup-merge.ts b/src/main/usage/usage-rollup-merge.ts new file mode 100644 index 00000000000..f63408f01a1 --- /dev/null +++ b/src/main/usage/usage-rollup-merge.ts @@ -0,0 +1,117 @@ +/** Combines rollups produced by separate sources (rollout files, sibling databases) of one provider. */ +import { + usageDailyAggregateKey, + type UsageDailyAggregate, + type UsageMetricFold, + type UsageSession +} from './usage-rollup-records' + +export type UsageRollupMergeOptions = { + fold: UsageMetricFold['fold'] + cloneSessionForMerge(session: UsageSession): UsageSession +} + +export function mergeUsageSessions( + target: Map>, + sessions: UsageSession[], + { fold, cloneSessionForMerge }: UsageRollupMergeOptions +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, cloneSessionForMerge(session)) + continue + } + + existing.firstTimestamp = + session.firstTimestamp < existing.firstTimestamp + ? session.firstTimestamp + : existing.firstTimestamp + existing.lastTimestamp = + session.lastTimestamp > existing.lastTimestamp + ? session.lastTimestamp + : existing.lastTimestamp + existing.eventCount += session.eventCount + existing.totalInputTokens += session.totalInputTokens + existing.totalCachedInputTokens += session.totalCachedInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens + existing.totalTokens += session.totalTokens + fold(existing, session) + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.eventCount += location.eventCount + existingLocation.inputTokens += location.inputTokens + existingLocation.cachedInputTokens += location.cachedInputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.reasoningOutputTokens += location.reasoningOutputTokens + existingLocation.totalTokens += location.totalTokens + fold(existingLocation, location) + } else { + existing.locationBreakdown.push({ ...location }) + } + } + + for (const model of session.modelBreakdown) { + const existingModel = + existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null + if (existingModel) { + existingModel.eventCount += model.eventCount + existingModel.inputTokens += model.inputTokens + existingModel.cachedInputTokens += model.cachedInputTokens + existingModel.outputTokens += model.outputTokens + existingModel.reasoningOutputTokens += model.reasoningOutputTokens + existingModel.totalTokens += model.totalTokens + fold(existingModel, model) + } else { + existing.modelBreakdown.push({ ...model }) + } + } + + for (const locationModel of session.locationModelBreakdown) { + const existingLocationModel = + existing.locationModelBreakdown.find( + (entry) => + entry.locationKey === locationModel.locationKey && + entry.modelKey === locationModel.modelKey + ) ?? null + if (existingLocationModel) { + existingLocationModel.eventCount += locationModel.eventCount + existingLocationModel.inputTokens += locationModel.inputTokens + existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens + existingLocationModel.outputTokens += locationModel.outputTokens + existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens + existingLocationModel.totalTokens += locationModel.totalTokens + fold(existingLocationModel, locationModel) + } else { + existing.locationModelBreakdown.push({ ...locationModel }) + } + } + } +} + +export function mergeUsageDailyAggregates( + target: Map>, + dailyAggregates: UsageDailyAggregate[], + fold: UsageMetricFold['fold'] +): void { + for (const aggregate of dailyAggregates) { + const key = usageDailyAggregateKey(aggregate) + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.eventCount += aggregate.eventCount + existing.inputTokens += aggregate.inputTokens + existing.cachedInputTokens += aggregate.cachedInputTokens + existing.outputTokens += aggregate.outputTokens + existing.reasoningOutputTokens += aggregate.reasoningOutputTokens + existing.totalTokens += aggregate.totalTokens + fold(existing, aggregate) + } +} diff --git a/src/main/usage/usage-rollup-records.ts b/src/main/usage/usage-rollup-records.ts new file mode 100644 index 00000000000..d7511e5714a --- /dev/null +++ b/src/main/usage/usage-rollup-records.ts @@ -0,0 +1,111 @@ +/** + * Record shapes every event-based usage provider (Codex, OpenCode, future plugin sources) + * rolls up to. Each provider carries exactly one extra metric alongside the token counters — + * Codex tracks inferred pricing, OpenCode tracks cost — so that metric stays generic instead + * of forcing a nullable union onto both. + */ + +export type UsageAttributedEventFields = { + sessionId: string + timestamp: string + model: string | null + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type UsageLocationBreakdown = { + locationKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} & TMetric + +export type UsageModelBreakdown = { + modelKey: string + modelLabel: string + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} & TMetric + +export type UsageLocationModelBreakdown = { + locationKey: string + modelKey: string + modelLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} & TMetric + +export type UsageSession = { + sessionId: string + firstTimestamp: string + lastTimestamp: string + primaryModel: string | null + hasMixedModels: boolean + primaryProjectLabel: string + hasMixedLocations: boolean + primaryWorktreeId: string | null + primaryRepoId: string | null + eventCount: number + totalInputTokens: number + totalCachedInputTokens: number + totalOutputTokens: number + totalReasoningOutputTokens: number + totalTokens: number + locationBreakdown: UsageLocationBreakdown[] + modelBreakdown: UsageModelBreakdown[] + locationModelBreakdown: UsageLocationModelBreakdown[] +} & TMetric + +export type UsageDailyAggregate = { + day: string + model: string | null + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null + eventCount: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} & TMetric + +/** How a provider seeds, reads, and combines its one extra metric. */ +export type UsageMetricFold = { + empty(): TMetric + fromEvent(event: TEvent): TMetric + fold(target: TMetric, source: TMetric): void +} + +export function usageDailyAggregateKey(record: { + day: string + model: string | null + projectKey: string +}): string { + return [record.day, record.model ?? 'unknown', record.projectKey].join('::') +} diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts new file mode 100644 index 00000000000..0b4347a90a5 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.ts @@ -0,0 +1,20 @@ +import type { Repo } from '../../shared/types' +import type { UsageScanWorktreeRef } from './usage-provider-contract' + +export function createWorktreeRefs( + repos: Repo[], + worktreesByRepo: Map +): UsageScanWorktreeRef[] { + const refs: UsageScanWorktreeRef[] = [] + for (const repo of repos) { + for (const worktree of worktreesByRepo.get(repo.id) ?? []) { + refs.push({ + repoId: repo.id, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + } + } + return refs +} diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 4159d7d89b4..381fef6643e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -82,6 +82,55 @@ describe('getSpawnArgsForWindows', () => { } }) + it('routes GUI Open In .cmd launches through start /B with an inner cmd /c', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( + 'C:\\Tools\\idea.cmd', + ['C:\\workspaces\\orca'], + { detachedGui: true } + ) + expect(spawnCmd).toBe(getCmdExePath()) + // Why: `start` runs a batch target under a nested `cmd /K` that never + // exits; the inner `cmd /d /c` is what keeps the hidden shell from leaking. + // Title is empty string so libuv emits `""` — not the two-char `'""'`. + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '', + '/B', + getCmdExePath(), + '/d', + '/c', + 'C:\\Tools\\idea.cmd', + 'C:\\workspaces\\orca' + ]) + expect(spawnArgs[3]).toBe('') + expect(spawnArgs).not.toContain('/K') + expect(spawnArgs).not.toContain('""') + expect(spawnArgs[spawnArgs.indexOf('/B') + 1]).not.toMatch(/\.(?:cmd|bat)$/i) + }) + }) + + it('keeps the waiting form for batch launches without detachedGui', () => { + withPlatform('win32', () => { + const { spawnArgs } = getSpawnArgsForWindows('C:\\Tools\\idea.cmd', ['C:\\workspaces\\orca']) + expect(spawnArgs).toEqual(['/d', '/c', 'C:\\Tools\\idea.cmd', 'C:\\workspaces\\orca']) + }) + }) + + it('leaves .exe GUI launches alone even when detachedGui is requested', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( + 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe', + ['C:\\workspaces\\orca'], + { detachedGui: true } + ) + expect(spawnCmd).toBe('C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe') + expect(spawnArgs).toEqual(['C:\\workspaces\\orca']) + }) + }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index c1031c7c55e..151dc9306f0 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -8,7 +8,8 @@ export { isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, - UnsafeWindowsBatchArgumentsError + UnsafeWindowsBatchArgumentsError, + type GetSpawnArgsForWindowsOptions } from '../shared/windows-batch-spawn' function execFileWithoutBlocking( diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index bc1e7ae258d..9bb9e35f9e3 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -160,8 +160,10 @@ function createMainWindow( } } -function createStore(): Store & { flush: MockFn } { - return { flush: vi.fn() } as Store & { flush: MockFn } +function createStore(): Store & { flushPendingAsync: MockFn } { + return { + flushPendingAsync: vi.fn(() => Promise.resolve()) + } as Store & { flushPendingAsync: MockFn } } function createRuntime(): RuntimeStub { @@ -299,7 +301,7 @@ describe('attachMainWindowServices', () => { await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit() expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1) - expect(store.flush).toHaveBeenCalledTimes(1) + expect(store.flushPendingAsync).toHaveBeenCalledTimes(1) }) it('flushes the store before update quit when no cleanup is injected', async () => { @@ -311,7 +313,7 @@ describe('attachMainWindowServices', () => { await fireReadyToShow(mainWindow) await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit() - expect(store.flush).toHaveBeenCalledTimes(1) + expect(store.flushPendingAsync).toHaveBeenCalledTimes(1) }) it('replaces the TCC handlers when the main window is reattached', () => { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 5984fe715af..b580b97e456 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -159,7 +159,7 @@ export function attachMainWindowServices( try { await options?.onBeforeUpdateQuit?.() } finally { - store.flush() + await store.flushPendingAsync() } }, setLastUpdateCheckAt: (timestamp) => { diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 8a61f157584..0764fa3ad93 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -74,8 +74,6 @@ import { } from './createMainWindow' import { ipcMain } from 'electron' import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' -import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' -import { fileURLToPath } from 'node:url' function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = process.platform @@ -266,35 +264,26 @@ describe('createMainWindow', () => { windowHandlers['did-attach-webview']({} as never, guest as never) expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) - const allowWindowCloseEvent = { preventDefault: vi.fn() } - const allowWindowCloseParams = { + const untrustedPreloadParams = { src: 'data:text/html,', - preload: BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + preload: 'file:///tmp/untrusted-preload.js' } - const allowWindowClosePrefs = { partition: 'persist:orca-browser' } - windowHandlers['will-attach-webview']( - allowWindowCloseEvent as never, - allowWindowClosePrefs as never, - allowWindowCloseParams as never - ) - expect(allowWindowCloseEvent.preventDefault).not.toHaveBeenCalled() - expect(allowWindowCloseParams.preload).toBeUndefined() - expect(allowWindowClosePrefs).not.toHaveProperty('preload') - - const allowWindowClosePathPrefs = { + const hardenedPrefs = { partition: 'persist:orca-browser', - preload: fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) + preload: '/tmp/untrusted-preload.js' } windowHandlers['will-attach-webview']( { preventDefault: vi.fn() } as never, - allowWindowClosePathPrefs as never, - { src: 'data:text/html,', preload: '' } as never + hardenedPrefs as never, + untrustedPreloadParams as never ) - expect(allowWindowClosePathPrefs).not.toHaveProperty('preload') + expect(untrustedPreloadParams.preload).toBeUndefined() + expect(hardenedPrefs.preload).toMatch(/browser-window-close-preload\.js$/) + expect(hardenedPrefs.preload).not.toContain('untrusted-preload') - const cliGuest = { marker: 'cli-guest' } - windowHandlers['did-attach-webview']({} as never, cliGuest as never) - expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(cliGuest) + const secondGuest = { marker: 'second-guest' } + windowHandlers['did-attach-webview']({} as never, secondGuest as never) + expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(secondGuest) }) it('sets platform-specific titlebar and frame options for every desktop platform', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 49c3eb4c125..3dc853e64a6 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -10,7 +10,6 @@ import { screen } from 'electron' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { is } from '@electron-toolkit/utils' import type { Store } from '../persistence' import { getAppIconPath } from '../app-icon' @@ -19,7 +18,6 @@ import { browserSessionRegistry } from '../browser/browser-session-registry' import { translateMain } from '../i18n/main-i18n' import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' -import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy' import { isCrashReportReason } from '../../shared/crash-reporting' import { DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, @@ -444,7 +442,6 @@ export function createMainWindow( registerPluginPanelNavigationGuard(mainWindow.webContents) const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js') - const browserWindowCloseAllowedPreloadPath = fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' const normalizedSrc = normalizeBrowserNavigationUrl(src) @@ -456,18 +453,9 @@ export function createMainWindow( return } - const allowWindowClose = [params.preload, webPreferences.preload].some( - (preload) => - preload === BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD || - preload === browserWindowCloseAllowedPreloadPath - ) delete params.preload - if (allowWindowClose) { - delete webPreferences.preload - } else { - // Why: preload runs in the page's main world before inline scripts can call window.close(). - webPreferences.preload = browserWindowClosePreload - } + // Why: preload runs in the page's main world before inline scripts can call window.close(). + webPreferences.preload = browserWindowClosePreload // Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge. delete (webPreferences as Record).preloadURL webPreferences.nodeIntegration = false diff --git a/src/main/worktree-removal-session-partition-fencing.test.ts b/src/main/worktree-removal-session-partition-fencing.test.ts new file mode 100644 index 00000000000..53b4dc805a6 --- /dev/null +++ b/src/main/worktree-removal-session-partition-fencing.test.ts @@ -0,0 +1,233 @@ +// Why this file exists: worktree removal writes to host session partitions, and the two hazards below +// are only visible across a removal followed by a renderer session write — persistence.test.ts covers +// removal and partitioning separately, so neither suite catches the interaction. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { TerminalTab } from '../shared/types' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { toRuntimeExecutionHostId } from '../shared/execution-host' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +const makeTerminalTab = (overrides: Partial = {}): TerminalTab => ({ + id: 'tab1', + ptyId: 'pty1', + worktreeId: 'repo::/worktree', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides +}) + +const ENV_A = toRuntimeExecutionHostId('env-a') +const STALE = 'repo-gone::/workspace/stale' +const LIVE = 'repo-gone::/workspace/live' + +describe('worktree removal across host session partitions', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Regression: removal fenced the never-written partition, so main's empty copy became authoritative + // and rebased away every sibling worktree's tabs on the first renderer write for that host. + it('keeps sibling tabs when a worktree is removed before its host partition was ever persisted', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + + store.removeWorktreeMeta(STALE) + + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [LIVE]: [makeTerminalTab({ id: 'live-tab', worktreeId: LIVE })] } + }, + ENV_A + ) + + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[LIVE]?.map((tab) => tab.id)).toEqual([ + 'live-tab' + ]) + }) + + it('does not materialize an unwritten host partition when removing a worktree', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + + store.removeWorktreeMeta(STALE) + + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + }) + + it('does not materialize an unwritten host partition when removing session state directly', async () => { + const store = await createStore() + + store.removeWorkspaceSessionStateForWorktree(STALE, ENV_A) + + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + }) + + // A persisted partition must still be fenced so main's copy wins over the renderer's stale tabs. + it('still fences a host partition that was persisted before the removal', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'stale-tab', worktreeId: STALE })] } + }, + ENV_A + ) + + store.removeWorktreeMeta(STALE) + + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() + expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) + + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'stale-tab', worktreeId: STALE })] } + }, + ENV_A + ) + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + }) + + // Regression: runtime removal purged only the host partition, so tabs the renderer had parked in the + // local blob (its fallback whenever worktree ownership is unresolved) leaked forever. + it('purges a runtime-owned worktree from the local blob as well as its host partition', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [STALE]: [makeTerminalTab({ id: 'local-fallback-tab', worktreeId: STALE })] + } + }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'host-tab', worktreeId: STALE })] } + }, + ENV_A + ) + + store.removeWorktreeMeta(STALE) + + expect(store.getWorkspaceSession().tabsByWorktree[STALE]).toBeUndefined() + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toBeUndefined() + }) + + // Regression: an empty local blob counted as "no sibling to rebase", so a runtime removal claimed + // repo-wide authority there and the renderer's next ownership-unresolved write lost its tabs. + it('does not fence the local blob for a runtime removal it never held tabs for', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'host-tab', worktreeId: STALE })] } + }, + ENV_A + ) + + store.removeWorktreeMeta(STALE) + + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [LIVE]: [makeTerminalTab({ id: 'live-tab', worktreeId: LIVE })] } + }) + + expect(store.getWorkspaceSession().tabsByWorktree[LIVE]?.map((tab) => tab.id)).toEqual([ + 'live-tab' + ]) + expect( + store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.['repo-gone'] + ).toBeUndefined() + }) + + // The owner's own partition still fences on emptiness: nothing of the repo lives there to rebase. + it('fences the owning partition that holds no tabs for the removed worktree', async () => { + const store = await createStore() + const otherRepoWorktree = 'repo-other::/workspace/live' + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [otherRepoWorktree]: [ + makeTerminalTab({ id: 'other-repo-tab', worktreeId: otherRepoWorktree }) + ] + } + }, + ENV_A + ) + + store.removeWorktreeMeta(STALE) + + expect(store.getWorkspaceSession(ENV_A).terminalTopologyRevisionByRepoId?.['repo-gone']).toBe(1) + + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [STALE]: [makeTerminalTab({ id: 'delayed-tab', worktreeId: STALE })] } + }, + ENV_A + ) + + expect(store.getWorkspaceSession(ENV_A).tabsByWorktree[STALE]).toEqual([]) + }) + + // The widened local purge must not fence the local blob when only siblings live there. + it('does not fence the local blob that holds only sibling worktrees of the removed runtime worktree', async () => { + const store = await createStore() + store.setWorktreeMeta(STALE, { hostId: ENV_A }) + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [LIVE]: [makeTerminalTab({ id: 'live-tab', worktreeId: LIVE })] } + }) + + store.removeWorktreeMeta(STALE) + + expect( + store.getWorkspaceSession().terminalTopologyRevisionByRepoId?.['repo-gone'] + ).toBeUndefined() + expect(store.getWorkspaceSession().tabsByWorktree[LIVE]?.map((tab) => tab.id)).toEqual([ + 'live-tab' + ]) + }) +}) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 368c4df4ff7..6e70b03033d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -481,6 +481,8 @@ import type { OpenCodeUsageSummary } from '../shared/opencode-usage-types' import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult, AiVaultListArgs, AiVaultListResult, AiVaultSubagentListArgs, @@ -884,6 +886,8 @@ export type AiVaultApi = { ) => Promise /** Lists the Task subagent transcripts of one session, on demand. */ listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise + /** Full first user prompt for copy/reuse (re-parses one transcript). */ + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise /** Fires when any app window regains OS focus; returns an unsubscribe. */ onWindowFocused: (callback: () => void) => () => void } @@ -968,9 +972,8 @@ export type AppApi = { /** Reloads the current app renderer through main so expected renderer * teardown can be classified before Electron emits process-gone events. */ reload: () => Promise - /** Commits the renderer's final locally durable state before unload and - * throws when the blocking durable write fails. */ - persistBeforeUnloadSync: (args: { + /** Stages the renderer's final state synchronously before unload. */ + stageBeforeUnloadSync: (args: { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial }) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index e2ca5656d00..cb38c18e9af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,7 +95,8 @@ import type { WorktreeBaseStatusEvent, WorktreeDefaultTabsLaunch, WorktreeHeadIdentity, - WorktreeRemoteBranchConflictEvent + WorktreeRemoteBranchConflictEvent, + WorktreeSetupLaunch } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { PtyListedSession } from '../shared/pty-listed-session' @@ -230,16 +231,18 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, - ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' -import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' import { ORCA_INTERNAL_FILE_DRAG_TYPE, createNativeFileDropPayload, @@ -278,7 +281,11 @@ import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart -} from './renderer-restart-preparation' +} from '../shared/renderer-restart-preparation' +import { + prepareAndInvokeUpdaterInstall, + registerRendererRestartIpcRelays +} from './renderer-restart-wiring' type NativeFileDropCallback = (data: NativeFileDropPayload) => void @@ -289,12 +296,7 @@ const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT ) -ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => { - updaterQuitAbortRelay.handleStatus(status) -}) -ipcRenderer.on('window:unload-prevented', () => { - window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) -}) +registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) function getLinuxDisplayServer(): 'wayland' | 'x11' | null { if (process.platform !== 'linux') { @@ -484,14 +486,12 @@ const api = { } }, reload: (): Promise => ipcRenderer.invoke('app:reload'), - persistBeforeUnloadSync: ( - args: Parameters[0] - ) => { - const result = ipcRenderer.sendSync('app:persist-before-unload-sync', args) as { + stageBeforeUnloadSync: (args: Parameters[0]) => { + const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown } if (result?.ok !== true) { - throw new Error('Failed to persist renderer state before unload.') + throw new Error('Failed to stage renderer state before unload.') } }, awaitFirstWindowStartupServices: (): Promise => @@ -1216,8 +1216,7 @@ const api = { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, - // Why: renderer declares serializer ownership for `paneKey` BEFORE pty:spawn so main suppresses the daemon-snapshot seed. - // The returned gen token must be echoed on settle/clear so paneKey reuse during teardown can't defeat the pre-signal. See docs/mobile-prefer-renderer-scrollback.md. + // Claim serializer ownership before spawn; echo the generation token on settle/clear to prevent pane-key reuse races. declarePendingPaneSerializer: (paneKey: string): Promise => ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), @@ -1945,7 +1944,7 @@ const api = { onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') }, - // Why: deliberately loose — main's validator (src/main/telemetry/validator.ts) is the single enforcement point; call sites use the typed wrappers in src/renderer/src/lib/telemetry.ts. + // Why: main validates telemetry; renderer call sites use typed wrappers. telemetryTrack: (name: string, props: Record): Promise => ipcRenderer.invoke('telemetry:track', name, props), telemetrySetOptIn: (optedIn: boolean): Promise => @@ -2870,8 +2869,7 @@ const api = { repoId: string worktreePath: string command: string - }): Promise<{ runnerScriptPath: string; envVars: Record }> => - ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), readIssueCommand: (args: { repoId: string @@ -2958,19 +2956,10 @@ const api = { dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), - quitAndInstall: async (): Promise => { - await prepareRendererForAppRestart(window, { - startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, - abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT - }) - updaterQuitAbortRelay.markPrepared() - try { - return await ipcRenderer.invoke('updater:quitAndInstall') - } catch (error) { - updaterQuitAbortRelay.abort() - throw error - } - }, + quitAndInstall: (): Promise => + prepareAndInvokeUpdaterInstall(window, updaterQuitAbortRelay, () => + ipcRenderer.invoke('updater:quitAndInstall') + ), onStatus: (callback) => { const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) ipcRenderer.on('updater:status', listener) @@ -3696,7 +3685,7 @@ const api = { callback: (data: { repoId: string worktreeId: string - setup?: { runnerScriptPath: string; envVars: Record } + setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch }) => void @@ -3706,7 +3695,7 @@ const api = { data: { repoId: string worktreeId: string - setup?: { runnerScriptPath: string; envVars: Record } + setup?: WorktreeSetupLaunch startup?: { command: string; env?: Record } defaultTabs?: WorktreeDefaultTabsLaunch } @@ -4164,6 +4153,8 @@ const api = { ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), onWindowFocused: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('aiVault:windowFocused', listener) diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts new file mode 100644 index 00000000000..56cd5d3dd9e --- /dev/null +++ b/src/preload/renderer-restart-wiring.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' +import { ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT } from '../shared/updater-renderer-events' +import { + prepareAndInvokeUpdaterInstall, + registerRendererRestartIpcRelays +} from './renderer-restart-wiring' + +describe('renderer restart wiring', () => { + it('relays updater status and prevented unload events', () => { + const eventTarget = new EventTarget() + const unloadPrevented = vi.fn() + const handleStatus = vi.fn() + const listeners = new Map void>() + const ipcRenderer = { + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + listeners.set(channel, listener) + return ipcRenderer + }) + } as unknown as Parameters[0] + eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented) + + registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus }) + listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' }) + listeners.get('window:unload-prevented')?.({}) + + expect(ipcRenderer.on).toHaveBeenCalledTimes(2) + expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' }) + expect(unloadPrevented).toHaveBeenCalledTimes(1) + }) + + it('marks preparation before invoking main and aborts on IPC failure', async () => { + const eventTarget = new EventTarget() + const calls: string[] = [] + eventTarget.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, () => { + calls.push('prepared') + }) + const relay = { + markPrepared: () => calls.push('marked'), + abort: () => calls.push('aborted') + } + const invoke = vi.fn(async () => { + calls.push('invoked') + throw new Error('IPC failed') + }) + + await expect(prepareAndInvokeUpdaterInstall(eventTarget, relay, invoke)).rejects.toThrow( + 'IPC failed' + ) + + expect(calls).toEqual(['prepared', 'marked', 'invoked', 'aborted']) + }) +}) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts new file mode 100644 index 00000000000..fbd3e26a71d --- /dev/null +++ b/src/preload/renderer-restart-wiring.ts @@ -0,0 +1,42 @@ +import type { IpcRenderer } from 'electron' +import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events' +import { + prepareRendererForAppRestart, + type UpdaterQuitAbortRelay +} from '../shared/renderer-restart-preparation' +import type { UpdateStatus } from '../shared/types' +import { + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, + ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT +} from '../shared/updater-renderer-events' + +export function registerRendererRestartIpcRelays( + ipcRenderer: Pick, + eventTarget: EventTarget, + relay: Pick +): void { + ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => { + relay.handleStatus(status) + }) + ipcRenderer.on('window:unload-prevented', () => { + eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT)) + }) +} + +export async function prepareAndInvokeUpdaterInstall( + eventTarget: EventTarget, + relay: Pick, + invoke: () => Promise +): Promise { + await prepareRendererForAppRestart(eventTarget, { + startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, + abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT + }) + relay.markPrepared() + try { + await invoke() + } catch (error) { + relay.abort() + throw error + } +} diff --git a/src/relay/agent-hook-envelope-publication.test.ts b/src/relay/agent-hook-envelope-publication.test.ts new file mode 100644 index 00000000000..164c36f9c7a --- /dev/null +++ b/src/relay/agent-hook-envelope-publication.test.ts @@ -0,0 +1,642 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { RelayDispatcher, type RelayClientSinkOptions } from './dispatcher' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + createShedSubagentsField +} from '../shared/agent-hook-relay' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import type { AgentSubagentSnapshot } from '../shared/agent-status-types' + +type BoundedClient = { + frames: Buffer[] + closes: number + options: RelayClientSinkOptions + write: (data: Buffer) => boolean + /** Mimics a socket whose kernel buffer is full: writes are accepted but never settle. */ + blocked: boolean + drain: () => void +} + +function makeBoundedClient(highWaterMark: number): BoundedClient { + const drainListeners = new Set<() => void>() + const client: BoundedClient = { + frames: [], + closes: 0, + blocked: false, + write: (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return !client.blocked + }, + drain: () => { + for (const listener of Array.from(drainListeners)) { + drainListeners.delete(listener) + listener() + } + }, + options: { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + waitWriteDrain: (callback: () => void) => { + drainListeners.add(callback) + return () => drainListeners.delete(callback) + }, + close: () => { + client.closes++ + } + } + } + return client +} + +/** Fills the producer queue so the next publish is rejected for backpressure, not for frame size. */ +function saturateProducerQueue(dispatcher: RelayDispatcher, client: BoundedClient): void { + client.blocked = true + // Shrinking chunks close the headroom a rejected 8 KB frame leaves behind, so even a small + // hook envelope is refused. + for (const size of [8_000, 512, 16]) { + const data = 'x'.repeat(size) + let filled = false + for (let attempt = 0; attempt < 5_000 && !filled; attempt++) { + filled = !dispatcher.tryNotifyPtyData({ id: 'pty-1', data }) + } + if (!filled) { + throw new Error('producer queue never filled') + } + } +} + +function decodePayload(frame: Buffer): Record { + const length = frame.readUInt32BE(9) + return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) +} + +/** `shedFields` is relay-emitted wire metadata; the typed envelope does not declare it yet. */ +type PublishedEnvelope = AgentHookRelayEnvelope & { shedFields?: string[] } + +function decodeEnvelopes(client: BoundedClient): PublishedEnvelope[] { + // Keepalive frames carry no payload; only regular frames decode as JSON-RPC. + return client.frames + .filter((frame) => frame.readUInt32BE(9) > 0) + .map((frame) => decodePayload(frame)) + .filter((msg) => msg.method === AGENT_HOOK_NOTIFICATION_METHOD) + .map((msg) => msg.params as unknown as PublishedEnvelope) +} + +function makeSubagents(count: number): AgentSubagentSnapshot[] { + return Array.from({ length: count }, (_, index) => ({ + id: `subagent-${index}-${'s'.repeat(40)}`, + agentType: 'general-purpose', + model: 'claude-opus', + description: 'd'.repeat(200), + state: 'working' as const, + startedAt: 1_700_000_000_000 + index + })) +} + +function makeEnvelope(sizes: { + lastAssistantMessage?: number + interactivePrompt?: number + subagents?: number +}): AgentHookRelayEnvelope { + return { + source: 'claude', + paneKey: 'tab-1:4f1b0f4e-0000-4000-8000-000000000001', + connectionId: null, + worktreeId: 'worktree-1', + payload: { + state: 'working', + prompt: 'p'.repeat(64), + agentType: 'claude', + model: 'claude-opus', + toolName: 'Bash', + toolInput: 'echo hi', + ...(sizes.lastAssistantMessage !== undefined + ? { lastAssistantMessage: 'a'.repeat(sizes.lastAssistantMessage) } + : {}), + ...(sizes.interactivePrompt !== undefined + ? { interactivePrompt: 'q'.repeat(sizes.interactivePrompt) } + : {}), + ...(sizes.subagents !== undefined ? { subagents: makeSubagents(sizes.subagents) } : {}) + } + } +} + +describe('publishAgentHookEnvelope', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('publishes an in-capacity envelope untouched', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ lastAssistantMessage: 128, interactivePrompt: 64 }) + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0]).toEqual(envelope) + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('sheds lastAssistantMessage first and stops as soon as the frame fits', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // Only lastAssistantMessage pushes this past the 12288-byte producer cap. + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 20_000, interactivePrompt: 128, subagents: 2 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(128)) + expect(published[0].payload.subagents).toHaveLength(2) + expect(published[0].payload.state).toBe('working') + expect(published[0].paneKey).toBe('tab-1:4f1b0f4e-0000-4000-8000-000000000001') + expect(published[0].connectionId).toBeNull() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('logs only when the final single-sink envelope is actually dropped', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + expect(decodeEnvelopes(primary)).toHaveLength(1) + expect(stderr).not.toHaveBeenCalled() + + const unsendable = makeEnvelope({}) + unsendable.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, unsendable) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('sheds subagents next when dropping the assistant message is not enough', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 8_000, interactivePrompt: 6_000, subagents: 40 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(6_000)) + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps the blocking question card when a waiting envelope has to shed', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // A blocked pane is only answerable from web/mobile if interactivePrompt survives the ladder. + const envelope = makeEnvelope({ + lastAssistantMessage: 8_000, + interactivePrompt: 4_000, + subagents: 40 + }) + envelope.payload.state = 'waiting' + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('waiting') + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(4_000)) + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('sheds interactivePrompt last and still delivers the status', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 6_000, interactivePrompt: 13_000, subagents: 2 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBeUndefined() + expect(published[0].payload.state).toBe('working') + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('shrinks an oversized waiting card instead of publishing waiting without it', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const envelope = makeEnvelope({ + lastAssistantMessage: 6_000, + subagents: 2 + }) + envelope.payload.state = 'waiting' + envelope.payload.interactivePrompt = JSON.stringify({ + questions: [ + { + question: 'q'.repeat(13_000), + options: [{ label: 'Keep' }, { label: 'Discard' }] + } + ] + }) + publishAgentHookEnvelope(dispatcher, envelope) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('waiting') + const card = JSON.parse(published[0].payload.interactivePrompt!) as { + questions: { question: string; options: { label: string }[] }[] + } + expect(card.questions[0].question.length).toBeLessThan(13_000) + expect(card.questions[0].options).toEqual([{ label: 'Keep' }, { label: 'Discard' }]) + expect(stderr).not.toHaveBeenCalled() + expect(primary.closes).toBe(0) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('drops an oversized waiting card when preserving its answer labels cannot fit', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const envelope = makeEnvelope({}) + envelope.payload.state = 'waiting' + envelope.payload.interactivePrompt = JSON.stringify({ + questions: [{ options: [{ label: 'answer'.repeat(3_000) }] }] + }) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + expect(primary.closes).toBe(0) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('never probes the budget for a single sink that accepts the frame', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const probe = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + try { + // One sink writes nothing when it rejects, so the publish attempt is itself the measurement. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 128 })) + expect(probe).not.toHaveBeenCalled() + + // Two of the three ladder steps are absent here; skipping them must not cost an extra encode. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ interactivePrompt: 20_000 })) + expect(probe).toHaveBeenCalledTimes(1) + expect(decodeEnvelopes(primary)).toHaveLength(2) + } finally { + probe.mockRestore() + dispatcher.dispose() + } + }) + + it('measures a fan-out up front so both sinks get the same payload', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + const probe = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + try { + dispatcher.attachClient(secondary.write, secondary.options) + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + // Probing first is what keeps the larger sink from being written a frame we then have to shed. + expect(probe).toHaveBeenCalledTimes(2) + for (const client of [primary, secondary]) { + expect(decodeEnvelopes(client)).toHaveLength(1) + } + } finally { + probe.mockRestore() + dispatcher.dispose() + } + }) + + it('skips absent fields instead of consuming a shed step on them', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + // No lastAssistantMessage at all: the ladder must move on to interactivePrompt. + publishAgentHookEnvelope(dispatcher, makeEnvelope({ interactivePrompt: 20_000 })) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.interactivePrompt).toBeUndefined() + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('publishes without closing the client when even the fully shed envelope is oversized', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({}) + envelope.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(primary.closes).toBe(0) + + // The sink still accepts the next in-capacity frame. + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) + + it('does not mutate the caller envelope, so the hook-server replay cache stays intact', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ + lastAssistantMessage: 6_000, + interactivePrompt: 6_000, + subagents: 40 + }) + const before = structuredClone(envelope) + + publishAgentHookEnvelope(dispatcher, envelope) + + expect(envelope).toEqual(before) + expect(envelope.payload.lastAssistantMessage).toBe('a'.repeat(6_000)) + expect(envelope.payload.subagents).toHaveLength(40) + } finally { + dispatcher.dispose() + } + }) + + it('sheds to the smallest attached sink so a replay reaches every client', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + try { + dispatcher.attachClient(secondary.write, secondary.options) + publishAgentHookEnvelope(dispatcher, makeEnvelope({ lastAssistantMessage: 20_000 })) + + for (const client of [primary, secondary]) { + const published = decodeEnvelopes(client) + expect(published).toHaveLength(1) + expect(published[0].payload.lastAssistantMessage).toBeUndefined() + expect(client.closes).toBe(0) + } + } finally { + dispatcher.dispose() + } + }) + + it('logs one multi-sink drop when the fully shed envelope remains unsendable', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + dispatcher.attachClient(secondary.write, secondary.options) + const envelope = makeEnvelope({}) + envelope.payload.prompt = 'p'.repeat(40_000) + publishAgentHookEnvelope(dispatcher, envelope) + publishAgentHookEnvelope(dispatcher, envelope) + + expect(decodeEnvelopes(primary)).toHaveLength(0) + expect(decodeEnvelopes(secondary)).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0][0])).toContain('Dropped agent.hook') + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) +}) + +describe('publishAgentHookEnvelope shed marker', () => { + it('names every shed field so a consumer can tell a shed roster from an absent one', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + publishAgentHookEnvelope( + dispatcher, + makeEnvelope({ lastAssistantMessage: 8_000, interactivePrompt: 6_000, subagents: 40 }) + ) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + // Without this marker the renderer overwrites a populated roster with undefined, blanking live + // subagent rows and unblocking hibernation for a pane that is still working. + expect(published[0].shedFields).toEqual([ + 'lastAssistantMessage', + createShedSubagentsField(makeSubagents(40)) + ]) + expect(published[0].payload.subagents).toBeUndefined() + expect(published[0].payload.interactivePrompt).toBe('q'.repeat(6_000)) + } finally { + dispatcher.dispose() + } + }) + + it('omits the marker when nothing was shed and never stamps the caller envelope', () => { + const primary = makeBoundedClient(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const envelope = makeEnvelope({ lastAssistantMessage: 128, subagents: 2 }) + publishAgentHookEnvelope(dispatcher, envelope) + expect(decodeEnvelopes(primary)[0].shedFields).toBeUndefined() + + const shedding = makeEnvelope({ lastAssistantMessage: 20_000, subagents: 2 }) + publishAgentHookEnvelope(dispatcher, shedding) + // The hook server replays this exact object, so the marker must live on the wire copy only. + expect((shedding as PublishedEnvelope).shedFields).toBeUndefined() + expect(shedding.payload.lastAssistantMessage).toBe('a'.repeat(20_000)) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('publishAgentHookEnvelope redelivery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('redelivers a backpressure-rejected envelope once the producer queue drains', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + const envelope = makeEnvelope({}) + envelope.payload.state = 'done' + publishAgentHookEnvelope(dispatcher, envelope) + expect(decodeEnvelopes(primary)).toHaveLength(0) + + primary.blocked = false + primary.drain() + // Nothing in the pipeline republishes a hook envelope: it is fire-and-forget, and the only + // other delivery path is the reattach replay. + expect(decodeEnvelopes(primary)).toHaveLength(0) + + vi.advanceTimersByTime(250) + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('done') + expect(published[0].paneKey).toBe('tab-1:4f1b0f4e-0000-4000-8000-000000000001') + expect(primary.closes).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps only the newest snapshot per pane instead of queueing every drop', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + for (const state of ['working', 'waiting', 'done'] as const) { + const envelope = makeEnvelope({}) + envelope.payload.state = state + publishAgentHookEnvelope(dispatcher, envelope) + } + + primary.blocked = false + primary.drain() + vi.advanceTimersByTime(1_000) + + const published = decodeEnvelopes(primary) + expect(published).toHaveLength(1) + expect(published[0].payload.state).toBe('done') + } finally { + dispatcher.dispose() + } + }) + + it('makes one final retry when capacity returns after the timer budget is exhausted', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + expect(vi.getTimerCount()).toBeGreaterThan(idleTimers) + + vi.advanceTimersByTime(250 * 41) + expect(vi.getTimerCount()).toBe(idleTimers) + + primary.blocked = false + primary.drain() + expect(decodeEnvelopes(primary)).toHaveLength(1) + + primary.drain() + vi.advanceTimersByTime(10_000) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) + + it('evicts the longest-idle pane once the pending map is full, keeping the newest', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + saturateProducerQueue(dispatcher, primary) + // 65 panes against a 64-pane cap: the first must be evicted, the last must survive. + for (let pane = 0; pane < 65; pane++) { + const envelope = makeEnvelope({}) + envelope.paneKey = `tab-1:4f1b0f4e-0000-4000-8000-${String(pane).padStart(12, '0')}` + publishAgentHookEnvelope(dispatcher, envelope) + } + + primary.blocked = false + primary.drain() + vi.advanceTimersByTime(1_000) + + const redelivered = decodeEnvelopes(primary).map((envelope) => envelope.paneKey) + expect(redelivered).toHaveLength(64) + expect(redelivered).toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000064') + expect(redelivered).not.toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000000') + } finally { + dispatcher.dispose() + } + }) + + it('drops pending redeliveries when the dispatcher is disposed', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + + dispatcher.dispose() + primary.blocked = false + vi.advanceTimersByTime(10_000) + + expect(vi.getTimerCount()).toBeLessThanOrEqual(idleTimers) + expect(decodeEnvelopes(primary)).toHaveLength(0) + }) + + it('cancels the retry when a later snapshot for the same pane gets through', () => { + const primary = makeBoundedClient(16384) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + try { + const idleTimers = vi.getTimerCount() + saturateProducerQueue(dispatcher, primary) + publishAgentHookEnvelope(dispatcher, makeEnvelope({})) + + primary.blocked = false + primary.drain() + const accepted = makeEnvelope({}) + accepted.payload.state = 'done' + publishAgentHookEnvelope(dispatcher, accepted) + expect(vi.getTimerCount()).toBe(idleTimers) + + vi.advanceTimersByTime(10_000) + expect(decodeEnvelopes(primary)).toHaveLength(1) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/agent-hook-envelope-publication.ts b/src/relay/agent-hook-envelope-publication.ts new file mode 100644 index 00000000000..96a413a02c7 --- /dev/null +++ b/src/relay/agent-hook-envelope-publication.ts @@ -0,0 +1,332 @@ +import { + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_SHED_FIELDS_KEY, + createShedSubagentsField, + type AgentHookRelayEnvelope +} from '../shared/agent-hook-relay' +import type { RelayDispatcher } from './dispatcher' + +// Why: shed the biggest, most reconstructible fields first — state/paneKey must survive or the pane +// stays stuck on its last spinner, and an over-capacity frame is now dropped rather than sent. +// Why: interactivePrompt is last on purpose — a blocking question is load-bearing (without its card +// the pane sits on `waiting` with no way to answer from web or mobile). The roster is shed earlier +// only because it is cheaper to rebuild, NOT because it is cosmetic: a missing roster blanks live +// subagent rows and unblocks pane hibernation, which is why shed fields are named on the wire — +// `restoreShedStatusFields` re-attaches the restorable ones from Orca's cached payload. +const SHED_ORDER = ['lastAssistantMessage', 'subagents', 'interactivePrompt'] as const + +// Why: these envelopes are fire-and-forget state snapshots — no ack, no producer-side retry — and +// the only other delivery path is the reattach replay. A queue-full drop would otherwise leave the +// pane on a stale spinner until the SSH link cycles, so a rejected snapshot is retried briefly. +const REDELIVERY_INTERVAL_MS = 250 +const MAX_REDELIVERY_ATTEMPTS = 40 +// Why: a wedged link must not pin snapshots for panes that are already gone; recency-evict the rest. +const MAX_PENDING_PANES = 64 + +type PendingEnvelope = { + params: Record + clientIds: readonly number[] + /** Zero means the timer budget is exhausted and one capacity-driven attempt remains. */ + attemptsLeft: number +} + +type RedeliveryState = { + pending: Map + timer: ReturnType | null +} + +const redeliveryByDispatcher = new WeakMap() +// Why: some agents submit option labels verbatim, so only presentation-only fields may shrink. +const COMPACTABLE_INTERACTIVE_PROMPT_FIELDS = new Set([ + 'description', + 'detail', + 'header', + 'question', + 'summary' +]) + +function fitsProducerFrame(dispatcher: RelayDispatcher, params: Record): boolean { + return dispatcher.producerEnvelopeBudget(AGENT_HOOK_NOTIFICATION_METHOD, params) >= 0 +} + +function toNotificationParams( + envelope: AgentHookRelayEnvelope, + shedFields: readonly string[] +): Record { + const params = envelope as unknown as Record + return shedFields.length === 0 + ? params + : { ...params, [AGENT_HOOK_SHED_FIELDS_KEY]: [...shedFields] } +} + +function compactInteractivePromptValue(value: unknown, maxStringLength: number): unknown { + if (Array.isArray(value)) { + return value.map((item) => compactInteractivePromptValue(item, maxStringLength)) + } + if (!value || typeof value !== 'object') { + return value + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + typeof item === 'string' && COMPACTABLE_INTERACTIVE_PROMPT_FIELDS.has(key) + ? item.slice(0, maxStringLength) + : compactInteractivePromptValue(item, maxStringLength) + ]) + ) +} + +function fitWaitingInteractivePrompt( + dispatcher: RelayDispatcher, + envelope: AgentHookRelayEnvelope, + shedFields: readonly string[] +): AgentHookRelayEnvelope | null { + const prompt = envelope.payload.interactivePrompt + if (!prompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(prompt) + } catch { + parsed = undefined + } + const promptAtLimit = (limit: number): string => + parsed === undefined + ? prompt.slice(0, limit) + : JSON.stringify(compactInteractivePromptValue(parsed, limit)) + let low = 1 + let high = prompt.length + let fitted: AgentHookRelayEnvelope | null = null + while (low <= high) { + const mid = Math.floor((low + high) / 2) + const candidate = { + ...envelope, + payload: { ...envelope.payload, interactivePrompt: promptAtLimit(mid) } + } + if (fitsProducerFrame(dispatcher, toNotificationParams(candidate, shedFields))) { + fitted = candidate + low = mid + 1 + } else { + high = mid - 1 + } + } + return fitted +} + +/** Returns the ids of the clients that rejected the frame; a rejected client is never written to. */ +function publishToClients( + dispatcher: RelayDispatcher, + params: Record, + clientIds: readonly number[], + logDrop?: boolean +): number[] { + const rejected: number[] = [] + for (const clientId of clientIds) { + if ( + !dispatcher.publishProducerNotification( + clientId, + AGENT_HOOK_NOTIFICATION_METHOD, + params, + logDrop === undefined ? undefined : { logDrop } + ) + ) { + rejected.push(clientId) + } + } + return rejected +} + +function logUnsendableEnvelope( + dispatcher: RelayDispatcher, + params: Record, + clientIds: readonly number[] +): void { + const rejectedClientId = clientIds.find( + (clientId) => + dispatcher.producerEnvelopeBudget(AGENT_HOOK_NOTIFICATION_METHOD, params, clientId) < 0 + ) + if (rejectedClientId !== undefined) { + publishToClients(dispatcher, params, [rejectedClientId], true) + } +} + +/** Publishes an agent-hook envelope, dropping optional detail fields until the frame fits the + * smallest attached sink. A frame the sink queue merely has no room for is retried in the + * background; one that no shedding can fit is dropped. */ +export function publishAgentHookEnvelope( + dispatcher: RelayDispatcher, + envelope: AgentHookRelayEnvelope +): void { + const clientIds = dispatcher.activeClientIds() + if (clientIds.length === 0) { + return + } + // Why: a fan-out must choose one payload before it writes anything, or the first client keeps a + // frame a smaller later client forces us to shed. A single sink writes nothing when it rejects, + // so there the attempt itself is the measurement — one encode instead of a probe plus a publish. + const measureBeforePublish = clientIds.length > 1 + let candidate = envelope + const shedFields: string[] = [] + let step = 0 + for (;;) { + const params = toNotificationParams(candidate, shedFields) + while (step < SHED_ORDER.length && candidate.payload[SHED_ORDER[step]] === undefined) { + step += 1 + } + if (!measureBeforePublish || fitsProducerFrame(dispatcher, params)) { + // A rejected intermediate probe is not a drop if a smaller envelope is delivered next. + const rejected = publishToClients( + dispatcher, + params, + clientIds, + measureBeforePublish ? undefined : step >= SHED_ORDER.length + ) + if (rejected.length === 0) { + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + // Rejection is ambiguous: an over-capacity frame must shed, a full producer queue must wait. + if (measureBeforePublish || fitsProducerFrame(dispatcher, params)) { + setPendingEnvelope(dispatcher, envelope.paneKey, params, rejected) + return + } + } + if (step >= SHED_ORDER.length) { + // Nothing left to shed and it still does not fit: waiting cannot make it sendable, and this + // snapshot supersedes any pending one, which is now stale. + if (measureBeforePublish) { + logUnsendableEnvelope(dispatcher, params, clientIds) + } + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + const field = SHED_ORDER[step] + step += 1 + if (field === 'interactivePrompt' && candidate.payload.state === 'waiting') { + const fitted = fitWaitingInteractivePrompt(dispatcher, candidate, shedFields) + if (fitted) { + candidate = fitted + continue + } + // A waiting snapshot without an answerable card must not reach web/mobile. + logUnsendableEnvelope(dispatcher, params, clientIds) + clearPendingEnvelope(dispatcher, envelope.paneKey) + return + } + // Why: the hook server caches envelopes and replays them after --connect, so shedding in place + // would permanently strip the cached copy too. + candidate = { ...candidate, payload: { ...candidate.payload } } + const shedField = + field === 'subagents' ? createShedSubagentsField(candidate.payload.subagents ?? []) : field + delete candidate.payload[field] + shedFields.push(shedField) + } +} + +function setPendingEnvelope( + dispatcher: RelayDispatcher, + paneKey: string, + params: Record, + clientIds: readonly number[] +): void { + let state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + state = { pending: new Map(), timer: null } + redeliveryByDispatcher.set(dispatcher, state) + // A disposed dispatcher can never accept the retry, and pending params pin the cached payload. + dispatcher.onDisposed(() => stopRedelivery(dispatcher)) + dispatcher.onLegacyPtyCapacity(() => runFinalCapacityPass(dispatcher)) + } + // Latest-wins: one snapshot per pane, never a growing queue. Delete-then-set keeps Map insertion + // order = recency so the cap below evicts the longest-idle pane. + state.pending.delete(paneKey) + state.pending.set(paneKey, { params, clientIds, attemptsLeft: MAX_REDELIVERY_ATTEMPTS }) + while (state.pending.size > MAX_PENDING_PANES) { + state.pending.delete(state.pending.keys().next().value as string) + } + if (!state.timer) { + const timer = setInterval(() => runRedeliveryPass(dispatcher), REDELIVERY_INTERVAL_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + state.timer = timer + } +} + +function clearPendingEnvelope(dispatcher: RelayDispatcher, paneKey: string): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state?.pending.delete(paneKey) || state.pending.size > 0) { + return + } + stopRedelivery(dispatcher) +} + +function stopRedelivery(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + state.pending.clear() + if (state.timer) { + clearInterval(state.timer) + state.timer = null + } +} + +function stopRedeliveryTimer(state: RedeliveryState): void { + if (state.timer) { + clearInterval(state.timer) + state.timer = null + } +} + +function runRedeliveryPass(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + const active = new Set(dispatcher.activeClientIds()) + for (const [paneKey, pending] of Array.from(state.pending)) { + if (pending.attemptsLeft <= 0) { + continue + } + // A client that detached gets caught up by the reattach replay, so stop tracking it. + const targets = pending.clientIds.filter((clientId) => active.has(clientId)) + const rejected = + targets.length === 0 ? [] : publishToClients(dispatcher, pending.params, targets) + pending.attemptsLeft -= 1 + if (rejected.length === 0) { + state.pending.delete(paneKey) + continue + } + pending.clientIds = rejected + } + if (state.pending.size === 0) { + stopRedelivery(dispatcher) + } else if (Array.from(state.pending.values()).every((pending) => pending.attemptsLeft <= 0)) { + stopRedeliveryTimer(state) + } +} + +function runFinalCapacityPass(dispatcher: RelayDispatcher): void { + const state = redeliveryByDispatcher.get(dispatcher) + if (!state) { + return + } + const active = new Set(dispatcher.activeClientIds()) + for (const [paneKey, pending] of Array.from(state.pending)) { + if (pending.attemptsLeft > 0) { + continue + } + // Delete before publishing because a synchronous sink settlement emits capacity reentrantly. + state.pending.delete(paneKey) + const targets = pending.clientIds.filter((clientId) => active.has(clientId)) + if (targets.length > 0) { + publishToClients(dispatcher, pending.params, targets) + } + } + if (state.pending.size === 0) { + stopRedelivery(dispatcher) + } +} diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index 9e3df40a930..3b941e6baee 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -26,7 +26,9 @@ import { AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH } from '../shared/agent-status-types' import { AgentHookServer } from '../main/agent-hooks/server' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' const LEAF_7 = '77777777-7777-4777-8777-777777777777' const LEAF_9 = '99999999-9999-4999-8999-999999999999' @@ -57,22 +59,25 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot onClose: () => {} } - dispatcher = new RelayDispatcher((data: Buffer) => { - setImmediate(() => { - for (const cb of clientDataCallbacks) { - cb(data) - } - }) - }) + dispatcher = new RelayDispatcher( + (data: Buffer) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }, + { + writableHighWaterMark: () => 4096, + writableLength: () => 0 + } + ) relayFeedFn = (data: Buffer) => dispatcher.feed(data) hookServer = new RelayAgentHookServer({ endpointDir: tmpDir, forward: (envelope) => { - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) + publishAgentHookEnvelope(dispatcher, envelope) } }) await hookServer.start() @@ -157,6 +162,42 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot expect(payload.agentType).toBe('claude') }) + it('sheds an oversized assistant message through the production publication path', async () => { + const events: { payload: { state: string; lastAssistantMessage?: string } }[] = [] + orcaServer.setListener((event) => { + events.push({ payload: event.payload }) + }) + const { port, token } = hookServer.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ paneKey: `tab-7:${LEAF_7}`, payload }) + }) + + await expect( + post({ hook_event_name: 'UserPromptSubmit', prompt: 'bounded publication' }) + ).resolves.toMatchObject({ status: 204 }) + await expect( + post({ + hook_event_name: 'Stop', + last_assistant_message: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) + }) + ).resolves.toMatchObject({ status: 204 }) + + const start = Date.now() + while (events.length < 2 && Date.now() - start < 1500) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(events).toHaveLength(2) + expect(events[1].payload.state).toBe('done') + expect(events[1].payload.lastAssistantMessage).toBeUndefined() + expect(dispatcher.activeClientIds()).toHaveLength(1) + }) + it('clears remote Claude permission when the approved tool starts with matching identity', async () => { const { port, token } = hookServer.getCoordinates() const postClaude = async (payload: Record): Promise => diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3d73506c7bf..32d5691cbe0 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -65,6 +65,7 @@ describe('RelayAgentHookServer', () => { expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') expect(envelope.payload.prompt).toBe('hi') + expect(envelope.claudeRunningNonAgentTask).toBe(false) // Why: the relay forwards body env/version so Orca's warn-once // protocol diagnostics and remote-location marker survive the wire. expect(envelope.env).toBe('remote') @@ -74,6 +75,36 @@ describe('RelayAgentHookServer', () => { } }) + it('forwards Claude background-work evidence with the normalized status', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { + hook_event_name: 'Stop', + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] + } + }) + }) + + expect(forward.mock.calls[0][0]).toMatchObject({ + claudeRunningNonAgentTask: true, + payload: { state: 'working', agentType: 'claude' } + }) + } finally { + server.stop() + } + }) + it('rejects requests with the wrong bearer token (403)', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb938f55d2b..a89b9d60c2b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -40,10 +40,10 @@ const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const CODEX_SUBAGENT_POLL_MS = 1_000 -// Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. +// Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. const MAX_HOOK_META_LEN = 64 -// Why: WSL relay has no per-pane teardown (PTYs live on the Windows host), so the replay cache would grow forever without a recency cap. +// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 function defaultEndpointDir(): string { @@ -96,7 +96,7 @@ export class RelayAgentHookServer { private endpointFilePath: string private endpointFileWritten = false private state: HookListenerState = createHookListenerState() - // Why: shared status cache drops wire-envelope fields; this sidecar holds source/env/version so replay matches the live POST path. + // Why: retain envelope metadata so replays match live POSTs. // Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep. private lastEnvelopeMetaByPaneKey: Map< string, @@ -128,7 +128,7 @@ export class RelayAgentHookServer { try { await this.listenOn(this.preferredPort) } catch (err) { - // Why: preferred port is best-effort; on EADDRINUSE fall back to ephemeral and let clients re-coordinate via the endpoint file. + // Why: fall back to an ephemeral port on EADDRINUSE; clients use the endpoint file. if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') { this.portFallbackApplied = true await this.listenOn(0) @@ -151,7 +151,7 @@ export class RelayAgentHookServer { return new Promise((resolve, reject) => { const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) - // Why: null the server ref on bind failure so a later start() can retry (else the early-return at top of start() wedges it). + // Why: clear failed server refs so later start() calls can retry. this.server = null reject(err) } @@ -316,6 +316,7 @@ export class RelayAgentHookServer { toolUseId: event.toolUseId, toolAgentId: event.toolAgentId, toolAgentType: event.toolAgentType, + claudeRunningNonAgentTask: event.claudeRunningNonAgentTask, ...(event.providerSession ? { providerSession: event.providerSession } : {}), ...(event.providerSessionOnly ? { providerSessionOnly: true } : {}), isReplay: options.isReplay === true ? true : undefined, diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts new file mode 100644 index 00000000000..a546989d5df --- /dev/null +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + RelayDispatcher, + type RelayClientSinkOptions, + type SinkWriteSettlement +} from './dispatcher' +import { DISPATCHER_CONTROL_QUEUE_MAX_FRAMES } from './dispatcher-writer-admission' +import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-ledger' +import type * as ProtocolModule from './protocol' +import { encodeJsonRpcFrame, RelayErrorCode } from './protocol' + +// Counts every frame encode (including the estimate-only ones) so a redundant re-encode is observable. +const encodeCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('./protocol', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + encodeJsonRpcFrame: (...args: Parameters) => { + encodeCalls.count++ + return actual.encodeJsonRpcFrame(...args) + } + } +}) + +type BoundedClient = { + frames: Buffer[] + closes: number + options: RelayClientSinkOptions + write: (data: Buffer) => boolean +} + +// Why: a sink that never accepts a write and never drains retains producer bytes, so the queue saturates +// while every individual frame stays comfortably inside the per-frame capacity. +function makeSaturatingClient(highWaterMark: number): BoundedClient { + const client = makeBoundedClient(highWaterMark) + client.write = (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return false + } + return client +} + +function makeBoundedClient(highWaterMark: number): BoundedClient { + const client: BoundedClient = { + frames: [], + closes: 0, + write: (data: Buffer) => { + client.frames.push(Buffer.from(data)) + return true + }, + options: { + writableHighWaterMark: () => highWaterMark, + writableLength: () => 0, + close: () => { + client.closes++ + } + } + } + return client +} + +function decodePayload(frame: Buffer): Record { + const length = frame.readUInt32BE(9) + return JSON.parse(frame.subarray(13, 13 + length).toString('utf-8')) +} + +describe('RelayDispatcher bounded-capacity degradation', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('producerEnvelopeBudget returns a negative budget for an over-cap envelope', () => { + const primary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + expect( + bounded.producerEnvelopeBudget('fs.changed', { events: ['x'.repeat(64)] }) + ).toBeGreaterThan(0) + expect( + bounded.producerEnvelopeBudget('fs.changed', { events: ['x'.repeat(20_000)] }) + ).toBeLessThan(0) + } finally { + bounded.dispose() + } + }) + + it('producerEnvelopeBudget takes the smallest client capacity unless a client is named', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const params = { events: ['x'.repeat(64)] } + const envelopeBytes = encodeJsonRpcFrame( + { jsonrpc: '2.0', method: 'fs.changed', params }, + 0, + 0 + ).length + + expect(bounded.activeClientIds()).toHaveLength(2) + expect(bounded.producerEnvelopeBudget('fs.changed', params)).toBe(12288 - envelopeBytes) + expect( + bounded.producerEnvelopeBudget('fs.changed', params, bounded.activeClientIds()[0]) + ).toBe(49152 - envelopeBytes) + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBe( + 12288 - envelopeBytes + ) + expect(bounded.producerEnvelopeBudget('fs.changed', params, 999)).toBe( + Number.MIN_SAFE_INTEGER + ) + } finally { + bounded.dispose() + } + }) + + it('producerEnvelopeBudget reports no capacity for a named client that is gone', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const params = { events: ['x'.repeat(64)] } + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBeGreaterThan(0) + + // A client that detached between activeClientIds() and publication must never look like it fits. + bounded.detachClient(secondaryId) + expect(bounded.producerEnvelopeBudget('fs.changed', params, secondaryId)).toBeLessThan(0) + expect(bounded.producerEnvelopeBudget('fs.changed', params, 999)).toBeLessThan(0) + // The no-target broadcast case still reports unbounded room. + expect(bounded.producerEnvelopeBudget('fs.changed', params)).toBeGreaterThan(0) + } finally { + bounded.dispose() + } + }) + + it('producerDataBudget keeps its pre-delegation value for a pty.data envelope', () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + // Snapshot of the shipped formula: 49152-byte producer capacity minus the 96-byte empty-data frame. + expect(bounded.producerDataBudget('pty.data', { ptyId: 'pty-7', seq: 42 })).toBe(49056) + // A vanished client leaves no room for data at all. + expect(bounded.producerDataBudget('pty.data', { ptyId: 'pty-7', seq: 42 }, 999)).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('producerDataBudget floors at zero when the envelope alone exceeds capacity', () => { + const primary = makeBoundedClient(1030) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + expect(bounded.producerDataBudget('pty.data', { ptyId: 'x'.repeat(4096) })).toBe(0) + } finally { + bounded.dispose() + } + }) + + it('notify drops an over-capacity frame and keeps the client open', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.notify('fs.changed', { events: ['x'.repeat(20_000)] }) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(0) + expect(stderr).toHaveBeenCalledTimes(1) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 12288B\)\n$/ + ) + + bounded.notify('pty.exit', { id: 'pty-1' }) + expect(primary.frames).toHaveLength(1) + expect(decodePayload(primary.frames[0]).method).toBe('pty.exit') + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('logs at most one drop per client generation', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + + const reattached = makeBoundedClient(16384) + bounded.setWrite(reattached.write, reattached.options) + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(2) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('distinguishes an over-capacity drop from a producer-queue-full drop on one client', () => { + const primary = makeSaturatingClient(4 * 1024 * 1024) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + // The first 1.5MB frame is retained by the stalled sink; the second exceeds the 2MB producer queue. + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + expect(primary.frames).toHaveLength(1) + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + bounded.notify('fs.changed', { events: ['x'.repeat(5_000_000)] }) + + expect(primary.closes).toBe(0) + expect(stderr).toHaveBeenCalledTimes(2) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B, producer queue full; frame capacity 4128768B\)\n$/ + ) + expect(stderr.mock.calls[1][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 4128768B\)\n$/ + ) + + // Both classifications stay one-per-generation once logged. + bounded.notify('fs.changed', { events: ['x'.repeat(1_500_000)] }) + bounded.notify('fs.changed', { events: ['x'.repeat(5_000_000)] }) + expect(stderr).toHaveBeenCalledTimes(2) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('logs each dropped method once instead of letting the first method silence the rest', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + bounded.notify('pty.data', flood) + bounded.notify('fs.changed', flood) + bounded.notify('pty.data', flood) + + expect(stderr).toHaveBeenCalledTimes(2) + expect(stderr.mock.calls[0][0]).toContain('Dropped fs.changed') + expect(stderr.mock.calls[1][0]).toContain('Dropped pty.data') + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('publishProducerNotification publishes on the producer lane and never closes', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', { events: [] })).toBe( + true + ) + expect(secondary.frames).toHaveLength(1) + expect(primary.frames).toHaveLength(0) + + expect( + bounded.publishProducerNotification(secondaryId, 'fs.changed', { + events: ['x'.repeat(20_000)] + }) + ).toBe(false) + expect(secondary.closes).toBe(0) + expect(secondary.frames).toHaveLength(1) + + expect(bounded.publishProducerNotification(999, 'fs.changed')).toBe(false) + } finally { + bounded.dispose() + } + }) + + it('still closes on protocol-critical control queue overflow', () => { + const primary = makeSaturatingClient(65536) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + expect(primary.closes).toBe(0) + + bounded.notifyClient(clientId, 'control.overflow') + expect(primary.closes).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('closes the client when pty.replay overflows the control queue', () => { + const primary = makeSaturatingClient(65536) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + bounded.notifyClient(clientId, `control.${index}`) + } + expect(primary.closes).toBe(0) + + // Replay is never retried, so an unnoticed drop would strand the pane on a short buffer. + bounded.notify('pty.replay', { paneKey: 'tab-1:pane-1', data: 'x' }) + expect(primary.closes).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('publishProducerNotification reports its drops like notify', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const secondaryId = bounded.attachClient(secondary.write, secondary.options) + const flood = { events: ['x'.repeat(20_000)] } + + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', flood)).toBe(false) + expect(stderr).toHaveBeenCalledTimes(1) + expect(stderr.mock.calls[0][0]).toMatch( + /^\[relay\] Dropped fs\.changed \(\d+B > producer frame capacity 12288B\)\n$/ + ) + + // Same one-line-per-generation budget as notify(). + expect(bounded.publishProducerNotification(secondaryId, 'fs.changed', flood)).toBe(false) + expect(stderr).toHaveBeenCalledTimes(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('does not re-encode a dropped frame when its log line is suppressed', () => { + const primary = makeBoundedClient(16384) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const flood = { events: ['x'.repeat(20_000)] } + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + + // The suppressed drop must size the frame once, not once to publish and again to log. + encodeCalls.count = 0 + bounded.notify('fs.changed', flood) + expect(stderr).toHaveBeenCalledTimes(1) + expect(encodeCalls.count).toBe(1) + } finally { + stderr.mockRestore() + bounded.dispose() + } + }) + + it('sendResponse substitutes a JSON-RPC error instead of closing when the legacy lane rejects', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.onRequest('fs.listFiles', async () => ({ paths: 'x'.repeat(3 * 1024 * 1024) })) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 77, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(1) + const response = decodePayload(primary.frames[0]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(response.id).toBe(77) + expect(response.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(response.error.message).toBe('Relay response exceeded the bounded transport capacity') + } finally { + bounded.dispose() + } + }) + + it('settles the response fence as failed when the substitute error replaces the result', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const settlements: SinkWriteSettlement[] = [] + bounded.onRequest('fs.listFiles', async (_params, context) => { + context.onResponseSettled?.((result) => settlements.push(result)) + return { paths: 'x'.repeat(3 * 1024 * 1024) } + }) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 79, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(settlements).toHaveLength(1) + // The peer got a capacity error, not the result — the fence must not claim delivery. + expect(settlements[0]).toEqual({ + ok: false, + error: new Error('Relay response exceeded the bounded transport capacity') + }) + } finally { + bounded.dispose() + } + }) + + it('producerRetentionBelowLowWater reports the reserve of one client, not the dispatcher', () => { + const stalled = makeSaturatingClient(65536) + const healthy = makeBoundedClient(65536) + const bounded = new RelayDispatcher(stalled.write, stalled.options) + try { + const stalledId = bounded.activeClientIds()[0] + const healthyId = bounded.attachClient(healthy.write, healthy.options) + let parked = 0 + while ( + parked <= LEGACY_CLIENT_RETAINED_BYTES_LOW && + bounded.publishProducerNotification(stalledId, 'pty.data', { + paneId: 'pane', + data: 'x'.repeat(40_000) + }) + ) { + parked += 40_000 + } + expect(parked).toBeGreaterThan(LEGACY_CLIENT_RETAINED_BYTES_LOW) + + expect(bounded.producerRetentionBelowLowWater(stalledId)).toBe(false) + expect(bounded.producerRetentionBelowLowWater(healthyId)).toBe(true) + // The dispatcher-wide signal is the one that lets a stalled peer speak for a healthy client. + expect(bounded.legacyRetentionBelowLowWater).toBe(false) + + // A client that cannot be written to has no headroom at all. + expect(bounded.producerRetentionBelowLowWater(999)).toBe(false) + bounded.detachClient(healthyId) + expect(bounded.producerRetentionBelowLowWater(healthyId)).toBe(false) + } finally { + bounded.dispose() + } + }) + + it('sendResponse settles an already-failed oversized response without closing', async () => { + const primary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + bounded.onRequest('fs.listFiles', async () => { + throw new Error('x'.repeat(3 * 1024 * 1024)) + }) + bounded.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 78, method: 'fs.listFiles' }, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + + expect(primary.closes).toBe(0) + expect(primary.frames).toHaveLength(1) + const response = decodePayload(primary.frames[0]) as unknown as { + id: number + error: { code: number; message: string } + } + expect(response.id).toBe(78) + expect(response.error.code).toBe(RelayErrorCode.ResponseOverCapacity) + expect(response.error.message).toBe('Relay response exceeded the bounded transport capacity') + } finally { + bounded.dispose() + } + }) +}) diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index 1966ce31dfc..dd9a2e3b245 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -59,6 +59,10 @@ export class DispatcherClientWriter { return this.sink.producerFrameCapacity } + canEnqueueControl(bytes: number): boolean { + return !this.closed && this.admission.canAdmitControl(bytes) + } + get fixedFrameCapacity(): number { return this.sink.frameCapacity('fixed-bulk', this.saturated) } @@ -83,7 +87,8 @@ export class DispatcherClientWriter { lane: DispatcherWriterLane, encode: () => Buffer, estimatedBytes: number, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + overflowIsNonFatal = false ): boolean { if (this.closed) { onSettled({ ok: false, error: new Error('Relay writer is closed') }) @@ -94,7 +99,8 @@ export class DispatcherClientWriter { encode, estimatedBytes, onSettled: onceDispatcherWriterSettlement(onSettled), - settled: false + settled: false, + overflowIsNonFatal } const admission = this.admission.admit(entry, this.sink.frameCapacity(lane, this.saturated)) if (!admission.accepted) { @@ -176,29 +182,18 @@ export class DispatcherClientWriter { } return this.laneScheduler.select( this.admission, - (entry) => this.canWriteControl(entry), - (entry) => this.canWriteProducer(entry) + (entry) => this.canWrite(entry, this.sink.highWaterMark), + (entry) => this.canWrite(entry, this.sink.producerFrameCapacity) ) } - private canWriteControl(entry: DispatcherWriterEntry): boolean { - const highWaterMark = this.sink.highWaterMark - if (!Number.isFinite(highWaterMark)) { + // Why: a zero-length writable always takes one frame, else a single oversized frame could never drain. + private canWrite(entry: DispatcherWriterEntry, capacity: number): boolean { + if (!Number.isFinite(this.sink.highWaterMark)) { return true } const writableLength = this.sink.writableLength - return writableLength + entry.estimatedBytes <= highWaterMark || writableLength === 0 - } - - private canWriteProducer(entry: DispatcherWriterEntry): boolean { - const highWaterMark = this.sink.highWaterMark - if (!Number.isFinite(highWaterMark)) { - return true - } - return ( - this.sink.writableLength + entry.estimatedBytes <= this.sink.producerFrameCapacity || - this.sink.writableLength === 0 - ) + return writableLength + entry.estimatedBytes <= capacity || writableLength === 0 } private writeEntry(entry: DispatcherWriterEntry): void { diff --git a/src/relay/dispatcher-writer-admission.ts b/src/relay/dispatcher-writer-admission.ts index a49627d4dcd..27504697f9f 100644 --- a/src/relay/dispatcher-writer-admission.ts +++ b/src/relay/dispatcher-writer-admission.ts @@ -15,13 +15,15 @@ export type DispatcherWriterEntry = { estimatedBytes: number onSettled: (result: DispatcherWriterSettlement) => void settled: boolean + // Why: control overflow closes the client by default; best-effort frames opt out of that instead. + overflowIsNonFatal?: boolean } type AdmissionResult = | { accepted: true; replaced?: DispatcherWriterEntry } | { accepted: false; error?: Error } -const CONTROL_QUEUE_MAX_FRAMES = 256 +export const DISPATCHER_CONTROL_QUEUE_MAX_FRAMES = 256 export const DISPATCHER_CONTROL_QUEUE_MAX_BYTES = 1024 * 1024 const LIVENESS_QUEUE_MAX_FRAMES = 2 @@ -68,6 +70,13 @@ export class DispatcherWriterAdmission { return this.producerBytes } + canAdmitControl(bytes: number): boolean { + return ( + this.controlFrames < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES && + this.controlBytes + bytes <= DISPATCHER_CONTROL_QUEUE_MAX_BYTES + ) + } + get queuedEntries(): number { return Object.values(this.queues).reduce((total, queue) => total + queue.length, 0) } @@ -143,10 +152,12 @@ export class DispatcherWriterAdmission { } private admitControl(entry: DispatcherWriterEntry): AdmissionResult { - if ( - this.controlFrames >= CONTROL_QUEUE_MAX_FRAMES || - this.controlBytes + entry.estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES - ) { + // Why: best-effort controls may fail soft without weakening protocol-critical admission. + const overflowIsNonFatal = entry.overflowIsNonFatal === true + if (!this.canAdmitControl(entry.estimatedBytes)) { + if (overflowIsNonFatal) { + return { accepted: false } + } return { accepted: false, error: new Error('Relay control queue exceeded its bounded capacity') diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index c6a1a77ec96..98e8959547f 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -6,6 +6,7 @@ import { encodeKeepAliveFrame, parseJsonRpcMessage, KEEPALIVE_SEND_MS, + RelayErrorCode, type DecodedFrame, type JsonRpcRequest, type JsonRpcNotification, @@ -69,9 +70,21 @@ type RelayClient = { highestReceivedSeq: number generation: number closed: boolean + droppedNotificationLog: DroppedProducerNotificationLog | null sessionIdentity: RelayClientSessionIdentity } +// Why: the log key set is rebuilt per generation, but a producer minting synthetic method names would still +// grow it inside one generation — cap it well above the fixed relay method vocabulary. +const DROPPED_NOTIFICATION_LOG_KEY_LIMIT = 64 + +const RESPONSE_OVER_CAPACITY_MESSAGE = 'Relay response exceeded the bounded transport capacity' + +type DroppedProducerNotificationLog = { + generation: number + loggedKeys: Set +} + type PendingRelayRequest = { resolve: (result: unknown) => void reject: (error: Error) => void @@ -91,6 +104,7 @@ export class RelayDispatcher { private clientDetachListeners = new Set<(clientId: number) => void>() private disposeListeners = new Set<() => void>() private legacyCapacityListeners = new Set<() => void>() + private clientCapacityListeners = new Map void>>() private publicationTransactionDepth = 0 private deferredLegacyCapacity = false private deferredForcedLegacyCapacity = false @@ -111,13 +125,15 @@ export class RelayDispatcher { } // Why: redirect outgoing frames to the reconnected socket without rebuilding the dispatcher + handler tree. - // Why: the new client's multiplexer restarts at seq=1, so reset seq/decoder state or acks stall and fire a false connection-dead signal. + // Why: a new multiplexer restarts at seq=1; reset state to avoid stalled acknowledgements. setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.closed = true this.primaryClient.writer.close(new Error('Relay primary sink replaced')) this.resetClient(this.primaryClient) this.primaryClient.writer = this.createWriter(this.primaryClient, write, sinkOptions) + // Why: a frame retained against the replaced sink would otherwise wait for traffic that may never come. + this.notifyClientCapacity(this.primaryClient.id) } // Why: mark in-flight requests stale on disconnect so a late pty.spawn/fs.watch can't create unowned remote state. @@ -176,10 +192,65 @@ export class RelayDispatcher { return () => this.legacyCapacityListeners.delete(listener) } + /** + * Ungated per-client writer capacity, for a frame that lost control-lane admission and must retry. + * onLegacyPtyCapacity cannot serve that: it is gated on producer retention, so it stays silent + * exactly under the dual-queue pressure that rejected the frame. Registered on the dispatcher, not + * the writer, so a retry survives setWrite() replacing the primary sink. + * Returns null only when the client is gone for good. A merely closed client still counts: setWrite + * marks the primary closed before replacing its sink, and that replacement is exactly when a frame + * stranded by the old writer must be armed to retry. + */ + onClientCapacity(clientId: number, listener: () => void): (() => void) | null { + const client = this.clients.get(clientId) + if (this.disposed || !client) { + return null + } + const listeners = this.clientCapacityListeners.get(clientId) ?? new Set<() => void>() + listeners.add(listener) + this.clientCapacityListeners.set(clientId, listeners) + return () => { + const current = this.clientCapacityListeners.get(clientId) + if (!current?.delete(listener) || current.size > 0) { + return + } + this.clientCapacityListeners.delete(clientId) + } + } + + /** + * Whether the id still names a client. A detach notification does not always mean it stopped: + * invalidateClient() detaches the primary without removing it, and setWrite() revives that same id. + */ + isClientAttached(clientId: number): boolean { + return !this.disposed && this.clients.has(clientId) + } + + canAdmitControlFrame(clientId: number, estimatedBytes: number): boolean { + const client = this.clients.get(clientId) + if (this.disposed || !client || client.closed) { + return false + } + return client.writer.canEnqueueControl(estimatedBytes) + } + get legacyRetentionBelowLowWater(): boolean { return this.publicationLedger.belowLowWater(this.activeClientKeys()) } + /** + * Same reserve as legacyRetentionBelowLowWater, but scoped to one client: a paced bulk producer + * gated on the dispatcher-wide signal stops for a peer's stall and degrades a healthy link. + * The relay-wide aggregate still counts — that ceiling is shared by every client. + */ + producerRetentionBelowLowWater(clientId: number): boolean { + const client = this.clients.get(clientId) + if (!client || client.closed) { + return false + } + return this.publicationLedger.belowLowWater([this.clientKey(client)]) + } + writePrimaryBytes(data: Buffer, lane: 'control' | 'ordinary' = 'control'): boolean { if (this.disposed || this.primaryClient.closed) { return false @@ -365,26 +436,44 @@ export class RelayDispatcher { ) } + activeClientIds(): number[] { + return this.activeClients().map((client) => client.id) + } + + // Why: signed on purpose — `budget >= 0` is an exact fits-check that a floored budget cannot express. + producerEnvelopeBudget( + method: string, + params: Record, + clientId?: number + ): number { + if (clientId !== undefined) { + const client = this.clients.get(clientId) + // Why: a detached or closed target has no room at all — reporting infinite capacity passes a + // fits-check and the frame is then dropped by the publish seam instead. + if (!client || client.closed) { + return Number.MIN_SAFE_INTEGER + } + return ( + client.writer.producerFrameCapacity - + this.estimateFrameBytes({ jsonrpc: '2.0', method, params }) + ) + } + const targets = this.activeClients() + if (targets.length === 0) { + return Number.MAX_SAFE_INTEGER + } + const frameBytes = this.estimateFrameBytes({ jsonrpc: '2.0', method, params }) + return Math.min(...targets.map((client) => client.writer.producerFrameCapacity - frameBytes)) + } + producerDataBudget( method: string, paramsWithoutData: Record, clientId?: number ): number { - const targets = - clientId === undefined - ? this.activeClients() - : [this.clients.get(clientId)].filter((client): client is RelayClient => !!client) - if (targets.length === 0) { - return Number.MAX_SAFE_INTEGER - } - const emptyFrameBytes = this.estimateFrameBytes({ - jsonrpc: '2.0', - method, - params: { ...paramsWithoutData, data: '' } - }) return Math.max( 0, - Math.min(...targets.map((client) => client.writer.producerFrameCapacity - emptyFrameBytes)) + this.producerEnvelopeBudget(method, { ...paramsWithoutData, data: '' }, clientId) ) } @@ -434,26 +523,82 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } + const frameBytes = this.estimateFrameBytes(msg) this.runPublicationTransaction(() => { for (const client of this.clients.values()) { if (client.closed) { continue } if (method === 'pty.replay') { - this.enqueueFrame(client, msg, 'control') + // Why: replay is never re-sent, so it takes the control lane where overflow is fatal — the + // writer closes the client and reconnect reloads history rather than stranding a short buffer. + this.enqueueFrame(client, msg, 'control', undefined, frameBytes) continue } - if (!this.publishToClient(client, msg, 'ordinary')) { - this.closeClient( - client, - new Error('Relay ordinary publication capacity exceeded'), - client !== this.primaryClient - ) + // Why: closing can never make an oversized frame sendable — the producer regenerates it after + // reattach and re-kills the link, turning a recoverable drop into an endless reconnect loop. + if (!this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + this.logDroppedProducerNotification(client, method, frameBytes) } } }) } + // Why: producer-lane publication for a single client; notifyClient/tryNotifyClient use the control lane, + // which floods must never occupy. Rejection drops the frame and never closes the client. + publishProducerNotification( + clientId: number, + method: string, + params?: Record, + // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in + // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. + options?: { logDrop?: boolean } + ): boolean { + if (this.disposed) { + return false + } + const client = this.clients.get(clientId) + if (!client || client.closed) { + return false + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + const frameBytes = this.estimateFrameBytes(msg) + if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + return true + } + // Why: same diagnostics as notify() — a producer that drops here must not do so silently. + if (options?.logDrop !== false) { + this.logDroppedProducerNotification(client, method, frameBytes) + } + return false + } + + // Why: one line per generation, method and drop reason — a flooding producer retries every batch and would + // spam stderr, but a transient queue-full drop must not consume the slot a real over-capacity drop needs. + private logDroppedProducerNotification(client: RelayClient, method: string, bytes: number): void { + const capacity = client.writer.producerFrameCapacity + const overCapacity = bytes > capacity + const key = `${method}:${overCapacity ? 'over-capacity' : 'queue-full'}` + let log = client.droppedNotificationLog + if (!log || log.generation !== client.generation) { + log = { generation: client.generation, loggedKeys: new Set() } + client.droppedNotificationLog = log + } + if (log.loggedKeys.has(key) || log.loggedKeys.size >= DROPPED_NOTIFICATION_LOG_KEY_LIMIT) { + return + } + log.loggedKeys.add(key) + process.stderr.write( + overCapacity + ? `[relay] Dropped ${method} (${bytes}B > producer frame capacity ${capacity}B)\n` + : `[relay] Dropped ${method} (${bytes}B, producer queue full; frame capacity ${capacity}B)\n` + ) + } + notifyClient(clientId: number, method: string, params?: Record): void { this.tryNotifyClient(clientId, method, params) } @@ -462,7 +607,11 @@ export class RelayDispatcher { clientId: number, method: string, params?: Record, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + options: { + controlOverflow?: 'close-client' | 'reject' + estimatedBytes?: number + } = {} ): boolean { if (this.disposed) { onSettled({ ok: false, error: new Error('Relay dispatcher is disposed') }) @@ -481,7 +630,9 @@ export class RelayDispatcher { ...(params !== undefined ? { params } : {}) }, 'control', - onSettled + onSettled, + options.estimatedBytes, + options.controlOverflow ) } @@ -624,6 +775,7 @@ export class RelayDispatcher { listener() } this.legacyCapacityListeners.clear() + this.clientCapacityListeners.clear() for (const listener of Array.from(this.disposeListeners)) { listener() } @@ -646,6 +798,7 @@ export class RelayDispatcher { highestReceivedSeq: 0, generation: 0, closed: false, + droppedNotificationLog: null, sessionIdentity: sessionIdentity ?? { principal: `unproved:${id}`, authenticated: false, @@ -840,16 +993,31 @@ export class RelayDispatcher { const estimatedBytes = this.estimateFrameBytes(msg) const lane = estimatedBytes > DISPATCHER_CONTROL_QUEUE_MAX_BYTES ? 'legacy-response' : 'control' const accepted = this.enqueueFrame(client, msg, lane, onSettled) - if (!accepted) { - this.closeClient( - client, - new Error( - `Relay response exceeds the bounded ${DEFAULT_PRODUCER_QUEUE_MAX_BYTES}-byte legacy lane` - ), - client !== this.primaryClient - ) + if (accepted) { + return true } - return accepted + // Why: an oversized response must fail its own request; closing would kill every pane on the host. + // A rejected first enqueue either left onSettled untouched or closed the client, so exactly one settlement happens. + return this.enqueueFrame( + client, + { + jsonrpc: '2.0', + id, + error: { + code: RelayErrorCode.ResponseOverCapacity, + message: RESPONSE_OVER_CAPACITY_MESSAGE + } + }, + 'control', + // Why: writing the substitute is not delivering the result — a settlement fence must never read + // the capacity error's successful write as "the peer received your result". + (settlement) => + onSettled( + settlement.ok + ? { ok: false, error: new Error(RESPONSE_OVER_CAPACITY_MESSAGE) } + : settlement + ) + ) } private enqueueFrame( @@ -858,20 +1026,23 @@ export class RelayDispatcher { lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, // Why: publish paths already sized the frame; avoid a redundant encode. - estimatedBytes?: number + estimatedBytes?: number, + controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false } const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) + const encode = (): Buffer => { + const seq = client.nextOutgoingSeq++ + return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) + } return client.writer.enqueue( lane, - () => { - const seq = client.nextOutgoingSeq++ - return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) - }, + encode, frameBytes, - onSettled + onSettled, + lane === 'control' && controlOverflow === 'reject' ) } @@ -972,9 +1143,11 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + // Why: broadcast callers size the frame once for every client; avoid a redundant encode. + estimatedBytes?: number ): boolean { - const bytes = this.estimateFrameBytes(msg) + const bytes = estimatedBytes ?? this.estimateFrameBytes(msg) const fixedBlocked = lane === 'fixed-bulk' && (client.writer.retainedProducerBytes > 0 || bytes > client.writer.fixedFrameCapacity) @@ -1063,10 +1236,29 @@ export class RelayDispatcher { const writer = new DispatcherClientWriter(write, sinkOptions, (error) => { this.closeClient(client, error, client !== this.primaryClient) }) - writer.onCapacity(() => this.notifyLegacyCapacityIfLow()) + writer.onCapacity(() => { + this.notifyLegacyCapacityIfLow() + this.notifyClientCapacity(client.id) + }) return writer } + private notifyClientCapacity(clientId: number): void { + const listeners = this.clientCapacityListeners.get(clientId) + if (!listeners?.size) { + return + } + for (const listener of Array.from(listeners)) { + try { + listener() + } catch (err) { + process.stderr.write( + `[relay] Client capacity listener failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + } + private closeClient(client: RelayClient, error: Error, remove: boolean): void { if (client.closed) { return @@ -1079,6 +1271,12 @@ export class RelayDispatcher { this.clients.delete(client.id) } this.notifyClientDetached(client.id) + if (remove) { + // Only for a client that is gone for good: an invalidated primary is revived by setWrite, and a + // frame stranded by its retired sink must stay armed to retry. After the detach fan-out, so a + // listener that unsubscribes on detach is not left holding a stale slot. + this.clientCapacityListeners.delete(client.id) + } this.notifyLegacyCapacity(true) if (!/^Relay (?:primary client invalidated|client detached)$/.test(error.message)) { process.stderr.write(`[relay] Client write closed: ${error.message}\n`) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 003172c08fd..4a6f663484d 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,8 +1,9 @@ import { open, readFile, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' -import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' +import { MAX_CONCURRENT_STREAMS, STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' +import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -102,12 +103,17 @@ export async function readRelayFileStreamMetadata( return { totalSize: 0, isBinary: true, empty: true } } - const handle = await open(filePath, 'r') + // Why: reserved before the fd opens so a refusal costs nothing, and released only + // once the terminal frame settles — see reserveTerminalFrameSlot. + const releaseTerminalFrameSlot = reserveTerminalFrameSlot(registry, context.clientId) + let handle: FileHandle | undefined let streamId: number try { + handle = await open(filePath, 'r') streamId = registry.register(handle) } catch (err) { - await handle.close() + await handle?.close() + releaseTerminalFrameSlot() throw err } @@ -118,7 +124,15 @@ export async function readRelayFileStreamMetadata( // sees the response before the first chunk frame. const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) + void pumpChunks( + streamId, + stats.size, + dispatcher, + registry, + context, + resolvedPumpOptions, + releaseTerminalFrameSlot + ) }) return { @@ -132,16 +146,60 @@ export async function readRelayFileStreamMetadata( } } +/** + * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not + * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's + * registry slot is gone the moment its last chunk is read, so on a socket that is not + * draining, back-to-back reads could stack one undelivered terminal frame each until + * that budget blew. Holding the slot until the frame settles keeps the number of queued + * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the + * overflow now costs one refused read (TooManyStreams, which clients already handle) + * instead of the whole connection. + * + * Counted per client, because the control queue this protects is per client: one peer whose + * socket stopped draining must not refuse reads for every other peer on the same relay. + */ +const pendingTerminalFramesByClient = new WeakMap>() + +function reserveTerminalFrameSlot(registry: RelayStreamRegistry, clientId: number): () => void { + let byClient = pendingTerminalFramesByClient.get(registry) + if (!byClient) { + byClient = new Map() + pendingTerminalFramesByClient.set(registry, byClient) + } + const pending = byClient.get(clientId) ?? 0 + if (pending >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + byClient.set(clientId, pending + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (byClient.get(clientId) ?? 1) - 1 + // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. + if (remaining <= 0) { + byClient.delete(clientId) + return + } + byClient.set(clientId, remaining) + } +} + async function pumpChunks( streamId: number, totalSize: number, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, context: RequestContext, - pumpOptions: StreamPumpOptions + pumpOptions: StreamPumpOptions, + releaseTerminalFrameSlot: () => void ): Promise { const entry = registry.get(streamId) if (!entry) { + releaseTerminalFrameSlot() return } const buffer = Buffer.allocUnsafe(STREAM_CHUNK_SIZE) @@ -150,6 +208,7 @@ async function pumpChunks( let endReason: 'end' | 'aborted' | 'stale' | 'error' = 'end' let errorCode: string | null = null let errorMessage: string | null = null + let slotReleaseDeferred = false try { try { @@ -226,11 +285,28 @@ async function pumpChunks( } try { + // Why: a dropped terminal frame hangs the reader forever, so it takes the control lane, which + // never drops — but that lane KILLS the link when it overflows, hence the reserved slot held + // until this frame settles. The per-chunk await already settled every chunk, so the control + // frame cannot overtake stream data. + const publishTerminal = (method: string, params: Record): void => { + if (pumpOptions.clientId === undefined) { + // Legacy broadcast path (direct calls/tests): no per-frame settlement to hold the slot on. + dispatcher.notifyControl(method, params) + return + } + slotReleaseDeferred = dispatcher.tryNotifyClient( + pumpOptions.clientId, + method, + params, + releaseTerminalFrameSlot + ) + } if (endReason === 'end') { - dispatcher.notify('fs.streamEnd', { streamId }) + publishTerminal('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) } else if (endReason === 'error') { - dispatcher.notify('fs.streamError', { + publishTerminal('fs.streamError', { streamId, code: errorCode ?? 'ESTREAMERROR', message: errorMessage ?? 'stream error' @@ -247,7 +323,12 @@ async function pumpChunks( ) } } finally { + // Why: the fd goes back first — a terminal frame that can never be delivered must not + // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. await registry.release(streamId) + if (!slotReleaseDeferred) { + releaseTerminalFrameSlot() + } } } @@ -274,7 +355,3 @@ async function readFullStreamChunk( } return totalRead } - -export function isTooManyStreamsError(err: unknown): err is TooManyStreamsError { - return err instanceof Error && (err as { code?: number }).code === RelayErrorCode.TooManyStreams -} diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index c7624afce65..ba6cfbf1877 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' import type { RelayDispatcher } from './dispatcher' -import { STREAM_CHUNK_SIZE } from './protocol' +import { MAX_CONCURRENT_STREAMS, STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'node:fs/promises' import * as path from 'node:path' import { mkdtempSync, writeFileSync } from 'node:fs' @@ -10,15 +10,27 @@ import { tmpdir } from 'node:os' vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -type Notification = { method: string; params?: Record } +type NotificationLane = 'producer' | 'bulk' | 'control' +type Notification = { + method: string + params?: Record + lane: NotificationLane + clientId?: number +} +type MockRequestContext = { clientId?: number; isStale: () => boolean } function createMockDispatcher() { const requestHandlers = new Map< string, - (params: Record, context?: { isStale: () => boolean }) => Promise + (params: Record, context?: MockRequestContext) => Promise >() const notificationHandlers = new Map) => void>() const notifications: Notification[] = [] + const droppedProducerFrames: Notification[] = [] + // Mirrors the real lane split: a saturated producer queue drops frames, control frames still land. + let producerSaturated = false + let holdControlSettlement = false + const heldControlSettlements: (() => void)[] = [] return { onRequest: vi.fn( ( @@ -32,16 +44,65 @@ function createMockDispatcher() { notificationHandlers.set(method, handler) }), notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) + const frame: Notification = { method, params, lane: 'producer' } + if (producerSaturated) { + droppedProducerFrames.push(frame) + return + } + notifications.push(frame) }), - notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { - notifications.push({ method, params }) + notifyBulk: vi.fn( + async ( + method: string, + params?: Record, + opts?: { clientId?: number } + ): Promise => { + notifications.push({ method, params, lane: 'bulk', clientId: opts?.clientId }) + } + ), + notifyClient: vi.fn((clientId: number, method: string, params?: Record) => { + notifications.push({ method, params, lane: 'control', clientId }) + }), + // Mirrors the real control lane: the frame is queued on enqueue, but settles only once the + // sink actually wrote it — holding settlement models a socket that is not draining. + tryNotifyClient: vi.fn( + ( + clientId: number, + method: string, + params?: Record, + onSettled?: (result: { ok: boolean }) => void + ): boolean => { + notifications.push({ method, params, lane: 'control', clientId }) + const settle = () => onSettled?.({ ok: true }) + if (holdControlSettlement) { + heldControlSettlements.push(settle) + } else { + settle() + } + return true + } + ), + notifyControl: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params, lane: 'control' }) }), _notifications: notifications, + _droppedProducerFrames: droppedProducerFrames, + saturateProducerLane() { + producerSaturated = true + }, + holdControlSettlements() { + holdControlSettlement = true + }, + settleHeldControlFrames() { + holdControlSettlement = false + for (const settle of heldControlSettlements.splice(0)) { + settle() + } + }, callRequest( method: string, params: Record = {}, - context?: { isStale: () => boolean } + context?: MockRequestContext ) { const handler = requestHandlers.get(method) if (!handler) { @@ -138,6 +199,60 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) + it('publishes fs.streamEnd after the final fs.streamChunk and targets the same client', async () => { + const filePath = path.join(tmpDir, 'targeted.png') + writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 7, isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).end !== null) + const frames = dispatcher._notifications + const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') + const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') + expect(lastChunkIndex).toBeGreaterThanOrEqual(0) + expect(endIndex).toBe(lastChunkIndex + 1) + expect(frames[endIndex]).toEqual({ + method: 'fs.streamEnd', + params: { streamId: meta.streamId }, + lane: 'control', + clientId: 7 + }) + expect(frames[lastChunkIndex].clientId).toBe(7) + }) + + // Real saturation is not constructible: the fixed-bulk lane admits a chunk only while producerBytes is 0, + // so a real backlog parks the chunk pump long before the terminal frame — hence the lane-tagging mock. + it('orders fs.streamEnd after the final chunk on the control lane and releases the stream', async () => { + const filePath = path.join(tmpDir, 'stream-end-release.png') + writeFileSync(filePath, Buffer.alloc(300 * 1024, 0x42)) + // Producer-lane frames now divert to the drop log, so an empty log proves no stream frame took that lane. + dispatcher.saturateProducerLane() + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 3, isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).end !== null) + expect(dispatcher._droppedProducerFrames).toHaveLength(0) + expect(collectStream(dispatcher).end).toEqual({ streamId: meta.streamId }) + + const frames = dispatcher._notifications + const endIndex = frames.findIndex((n) => n.method === 'fs.streamEnd') + const lastChunkIndex = frames.map((n) => n.method).lastIndexOf('fs.streamChunk') + expect(lastChunkIndex).toBeGreaterThanOrEqual(0) + expect(endIndex).toBe(lastChunkIndex + 1) + expect(frames[endIndex].lane).toBe('control') + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + }) + it('fills protocol chunks when fs.read returns short before EOF', async () => { const filePath = path.join(tmpDir, 'short-read.png') const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) @@ -336,6 +451,47 @@ describe('FsHandler readFileStream', () => { expect(err).toBeNull() }) + it('caps undelivered terminal frames so they cannot overflow the link-killing control lane', async () => { + const filePath = path.join(tmpDir, 'terminal-budget.png') + writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) // one chunk per stream + // A socket that accepts nothing: control frames queue, and the real writer destroys the + // client once 256 of them pile up. + dispatcher.holdControlSettlements() + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + const context = { clientId: 5, isStale: () => false } + const terminalFrames = () => + dispatcher._notifications.filter( + (n) => n.method === 'fs.streamEnd' || n.method === 'fs.streamError' + ) + + for (let i = 0; i < MAX_CONCURRENT_STREAMS; i++) { + await dispatcher.callRequest('fs.readFileStream', { filePath }, context) + await waitFor(() => terminalFrames().length === i + 1) + } + // The fd must go back even though its terminal frame is still undelivered. + await waitFor(() => registry.size() === 0) + + await expect( + dispatcher.callRequest('fs.readFileStream', { filePath }, context) + ).rejects.toThrow(/Too many concurrent streams/) + await flush(10) + expect(terminalFrames()).toHaveLength(MAX_CONCURRENT_STREAMS) + + // The budget is per client, so a second peer on the same relay still reads. + await dispatcher.callRequest( + 'fs.readFileStream', + { filePath }, + { clientId: 9, isStale: () => false } + ) + await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 1) + + // Draining the sink settles the queued frames and hands the budget back. + dispatcher.settleHeldControlFrames() + await dispatcher.callRequest('fs.readFileStream', { filePath }, context) + await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) + }) + it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 8058d55ae29..40607bdac0d 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -191,6 +191,9 @@ describe('removeWorktreeOp branch cleanup', () => { if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { return { stdout: 'base123\n', stderr: '' } } + if (args[0] === 'rev-parse' && args.includes('HEAD^{commit}')) { + return { stdout: 'base123\n', stderr: '' } + } if (args[0] === 'merge-tree') { return { stdout: 'tree123\n', stderr: '' } } @@ -217,6 +220,7 @@ describe('removeWorktreeOp branch cleanup', () => { ['config', '--remove-section', 'branch.feature/test'], expect.any(String) ) + expect(git).not.toHaveBeenCalledWith(['remote'], expect.any(String)) }) it('deletes a squash-merged SSH branch with branch-only merge commits via expected head', async () => { @@ -360,17 +364,17 @@ describe('removeWorktreeOp branch cleanup', () => { const commandIndex = (expectedArgs: string[]) => calls.findIndex(({ args }) => JSON.stringify(args) === JSON.stringify(expectedArgs)) const fetchIndex = commandIndex(['fetch', '--prune', 'origin']) - const mergeTreeIndex = commandIndex([ - 'merge-tree', - '--write-tree', - 'base123', - 'refs/heads/feature/test' - ]) + const mergeTreeArgs = ['merge-tree', '--write-tree', 'base123', 'refs/heads/feature/test'] + const mergeTreeIndexes = calls.flatMap(({ args }, index) => + JSON.stringify(args) === JSON.stringify(mergeTreeArgs) ? [index] : [] + ) const updateRefIndex = commandIndex(['update-ref', '-d', 'refs/heads/feature/test', '1']) expect(fetchIndex).toBeGreaterThanOrEqual(0) expect(calls[fetchIndex]?.cwd).toBe(resolvedRepoPath()) - expect(fetchIndex).toBeLessThan(mergeTreeIndex) + expect(mergeTreeIndexes).toHaveLength(1) + expect(fetchIndex).toBeLessThan(mergeTreeIndexes[0]) + expect(mergeTreeIndexes[0]).toBeLessThan(updateRefIndex) expect(fetchIndex).toBeLessThan(updateRefIndex) }) diff --git a/src/relay/git-handler-branch-cleanup.ts b/src/relay/git-handler-branch-cleanup.ts index 51aa0be521a..1a195d59cbd 100644 --- a/src/relay/git-handler-branch-cleanup.ts +++ b/src/relay/git-handler-branch-cleanup.ts @@ -1,7 +1,6 @@ import { - branchHasNoUnmergedChangesOnAnyTarget, - getBranchCleanupTargetRefs, - refreshBranchCleanupTargetRefs + branchHasNoUnmergedChangesWithLazyTargetRefresh, + getBranchCleanupTargetRefs } from '../shared/git-branch-cleanup' import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' @@ -17,12 +16,16 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( const runGit = (args: string[], options?: { stdin?: string }) => options ? git(args, repoPath, options) : git(args, repoPath) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) - await refreshBranchCleanupTargetRefs(runGit, targetRefs) // Why: SSH worktrees hit the same squash-merge shape as local worktrees. // Git's no-op merge proof lets us clean up only branches whose changes // already exist on the saved base ref. if ( - !(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs, capabilities)) + !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( + runGit, + branchName, + targetRefs, + capabilities + )) ) { return false } diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 1bd05821f50..880e690ee54 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -133,7 +133,7 @@ export async function getStatusOp( const branchName = getShortBranchName(branch) if (branchName) { try { - // Why: this probe coalesces across concurrent status reads, so one request's abort must not reject the shared in-flight promise. + // Why: one request's abort must not reject this shared status probe. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), @@ -171,7 +171,7 @@ export async function getStatusOp( // not a git repo or git not available } - // Why: skip line-stats when the limit was hit — numstat over a huge change set would reintroduce the cost the limit avoids. + // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index d883c742425..6674500c6f9 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -112,7 +112,7 @@ function resolveRelayPath(repoPath: string, value: string): string { if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { return value } - // Old git ignores `--path-format=absolute`; resolve relative toplevel/git-dir against repoPath, picking the win32/posix resolver by its shape. + // Old Git ignores `--path-format=absolute`; resolve relative paths against repoPath by path shape. return isWindowsAbsolutePath(repoPath) ? path.win32.resolve(repoPath, value) : path.posix.resolve(repoPath, value) @@ -176,10 +176,10 @@ export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() private readonly gitCapabilities = new GitCapabilityCache() - // Why: large diff/exec responses go on the bulk lane so they don't head-of-line-block interactive pty.data echo on the shared SSH channel. + // Why: use the bulk lane so large responses do not block interactive PTY echo. private readonly responseStreams = new GitResponseStreamRegistry() - // Why: instance-level TTL cache avoids re-reading `.gitmodules` per diff click over SSH; per-instance so it can't leak across tests. + // Why: cache .gitmodules per instance to avoid SSH reads and test leakage. private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() // Why: RelayContext accepted for protocol back-compat (docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. @@ -360,7 +360,7 @@ export class GitHandler { }) } - // Why: parent status lists one gitlink row per submodule; fetch inner per-file changes by running status inside the submodule's own worktree. + // Why: fetch per-file submodule changes from the submodule worktree. private async getSubmoduleStatus(params: Record, context: RequestContext) { const worktreePath = params.worktreePath as string const submodulePath = params.submodulePath as string @@ -383,7 +383,7 @@ export class GitHandler { // Why: pointer/range probes are part of the same SSH request and must not outlive its cancellation. const requestGit: GitExec = (args, cwd, options) => this.git(args, cwd, { ...options, signal: context.signal }) - // Why: a moved gitlink (clean worktree) has no uncommitted rows; surface files changed between recorded and checked-out commits so it isn't empty. + // Why: moved clean gitlinks need committed changes surfaced. const { fromOid, toOid } = await resolveSubmoduleCommitRange( requestGit, worktreePath, @@ -426,7 +426,7 @@ export class GitHandler { private async getDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string - // Why: filePath is relative and joined for readWorkingFile; validate or `../../etc/passwd` traverses outside the worktree. + // Why: validate relative paths to prevent traversal outside the worktree. const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { @@ -434,11 +434,11 @@ export class GitHandler { } const staged = params.staged as boolean const compareAgainstHead = params.compareAgainstHead as boolean | undefined - // Why: register the in-flight dedupe synchronously (before any await) so concurrent identical reads coalesce; submodule routing happens inside. + // Why: register dedupe before awaiting so identical reads coalesce. const result = await this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => { - // Why: gitlinks can't be read as blobs, so route the gitlink root to a pointer diff and inner files into the submodule's own worktree. + // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. const submodulePaths = await listSubmodulePathsCached( this.git.bind(this), worktreePath, @@ -775,13 +775,13 @@ export class GitHandler { private async branchCompare(params: Record) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string - // Why: a baseRef starting with '-' would be read as a git rev-parse flag, potentially leaking environment variables or config. + // Why: reject flag-like base refs to prevent rev-parse option injection. if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } const gitBound = this.git.bind(this) return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { - // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; without it parseBranchDiff would get C-style octal-escaped paths. + // Why: preserve non-ASCII filenames as UTF-8 for parseBranchDiff. const [{ stdout }, { stdout: numstat }] = await Promise.all([ gitBound( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], @@ -821,7 +821,7 @@ export class GitHandler { (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) ) } catch (error) { - // Why: swallow only 'no upstream configured' (an expected state); other errors (auth, corruption, network) must surface to the user. + // Why: suppress only the expected no-upstream error; surface all others. if (isNoUpstreamError(error)) { return { hasUpstream: false, ahead: 0, behind: 0 } } @@ -1113,7 +1113,7 @@ export class GitHandler { } private async pull(params: Record) { - // Why: plain `git pull` honors the user's merge/rebase/ff policy; with none, Git's policy error is normalized with setup guidance. + // Why: plain `git pull` honors user merge/rebase/ff policy. await this.pullWithArgs(params, []) } diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 0fbd85cc8fb..8cb4ab57a75 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -102,7 +102,10 @@ export type GitResponseStreamMarker = { export const RelayErrorCode = { TooManyStreams: -33006, - StreamProtocolError: -33007 + StreamProtocolError: -33007, + /** Substituted for a response too large for the sink's frame capacity; the request fails + * instead of the whole link, so a caller can retry with a narrower scope. */ + ResponseOverCapacity: -33008 } as const export type JsonRpcRequest = { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 1c1ff41328d..f9e68447b43 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -687,6 +687,60 @@ describe('PtyHandler', () => { } }) + // Why: both spellings classify as a POSIX startup family, so the relay must not be the one host + // that hard-fails a setting the local and daemon PTYs accept. + it.each(['bash', 'bash.exe'])( + 'accepts the %s shell override and routes it through Git Bash resolution', + async (shellOverride) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe') + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride }) + + expect(resolveGitBashSpy).toHaveBeenCalledWith(shellOverride) + expect(mockPtySpawn).toHaveBeenCalledWith( + 'C:\\Program Files\\Git\\bin\\bash.exe', + expect.any(Array), + expect.any(Object) + ) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + + it('falls back to the literal bash override when Git Bash is not installed', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const resolveGitBashSpy = vi + .spyOn(gitBash, 'resolveWindowsGitBashShellPath') + .mockReturnValue(null) + try { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride: 'bash' }) + + expect(mockPtySpawn).toHaveBeenCalledWith('bash', expect.any(Array), expect.any(Object)) + } finally { + resolveGitBashSpy.mockRestore() + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cc685389f0a..c928bd4a106 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -180,7 +180,7 @@ type ManagedStartupCommand = { timer: ReturnType | null } -// Why: node-pty's Windows agent throws on any signal arg (ConPTY has no signal semantics); drop it there, forward on POSIX. +// Why: Windows ConPTY rejects signals; forward them only on POSIX. function killPtyProcess(pty: IPty, signal: string): void { if (process.platform === 'win32') { pty.kill() @@ -263,6 +263,10 @@ const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([ 'cmd', 'wsl.exe', 'wsl', + // Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay + // the one host that hard-failed a setting the local and daemon PTYs accept. + 'bash.exe', + 'bash', WINDOWS_GIT_BASH_SHELL ]) @@ -374,7 +378,7 @@ export class PtyHandler { private exitListener: PtyExitListener | null = null private ptyPoolEmptyListener: (() => void) | null = null private ptyPoolActiveListener: (() => void) | null = null - // Why: env augmenters run on every spawn so each PTY sees live hook coords without the dispatcher knowing about agent hooks. + // Why: augment environment on every spawn so PTYs receive current hook coordinates. private envAugmenters: PtyEnvAugmenter[] = [] private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionCreateOperations = new Map< @@ -1590,7 +1594,7 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } - // Why: a shell can die without node-pty firing onExit (reaped out-of-band); prove liveness so attach doesn't strand a dead, lingering lease. + // Why: verify liveness because shells can exit without node-pty onExit. if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() this.releaseRelayIngress(managed) @@ -1603,7 +1607,7 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } - // Why: a relay generation reset can reuse pty-N for a different pane; reject on identity disagreement (absent identity permissive). + // Why: generation resets can reuse PTY IDs; reject conflicting identities. const mismatch = attachIdentityMismatches( { paneKey: typeof params.expectedPaneKey === 'string' ? params.expectedPaneKey : undefined, @@ -1646,8 +1650,8 @@ export class PtyHandler { } } - // Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early. - // Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history. + // Why: return replay during spawn before renderer handlers register. + // Why: retain replay buffers so later restarts receive full history. const replay = managed.buffered.read() if (replay) { // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. @@ -1715,7 +1719,7 @@ export class PtyHandler { this.releaseStartupCommand(managed) this.flushPtyOutput(id) this.requestForceKill(managed) - // Why: remote Git deletion must not race the child's native handles; on timeout keep the map entry so onExit/retry still owns it. + // Why: preserve timed-out entries so onExit/retry owns native handles. await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) } else { this.releaseStartupCommand(managed) diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 8c4c0134a2a..de924230f0f 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -61,18 +61,52 @@ class FakeWatcherPool { function createDispatcher() { const detached = new Set<(clientId: number) => void>() + // Batches ride the producer lane and markers the control lane, so record both in one ordered log. + const fsChanged: Record[] = [] + const record = (_clientId: number, method: string, params?: Record): void => { + if (method === 'fs.changed' && params) { + fsChanged.push(params) + } + } return { + fsChanged, notify: vi.fn(), - notifyClient: vi.fn(), - broadcastProducerFrameCapacity: vi.fn(() => Number.MAX_SAFE_INTEGER), - notificationFrameBytes: vi.fn(() => 64), + notifyClient: vi.fn(record), + tryNotifyClient: vi.fn( + ( + clientId: number, + method: string, + params?: Record, + onSettled?: (result: { ok: true } | { ok: false; error: Error }) => void + ) => { + record(clientId, method, params) + // A healthy sink settles as the frame is written, which releases the emitter's outstanding-marker slot. + onSettled?.({ ok: true }) + return true + } + ), + activeClientIds: vi.fn(() => [1]), + producerEnvelopeBudget: vi.fn(() => Number.MAX_SAFE_INTEGER), + publishProducerNotification: vi.fn( + (clientId: number, method: string, params?: Record) => { + record(clientId, method, params) + return true + } + ), onClientDetached: vi.fn((listener: (clientId: number) => void) => { detached.add(listener) return () => detached.delete(listener) - }) + }), + // Detach here always retires the id, so the emitter's marker cleanup runs. + isClientAttached: vi.fn(() => false), + notificationFrameBytes: vi.fn(() => 64) } } +function watchFailedCalls(dispatcher: ReturnType): unknown[][] { + return dispatcher.notifyClient.mock.calls.filter(([, method]) => method === 'fs.watchFailed') +} + function context(clientId: number): RequestContext { return { clientId, isStale: () => false } } @@ -96,10 +130,10 @@ describe('RelayFilesystemWatchRegistry', () => { first.hooks.onInterruption?.() first.callback(null, [{ type: 'update', path: '/repo/after-resubscribe.txt' }]) - expect(dispatcher.notify.mock.calls).toEqual([ - ['fs.changed', { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }], - ['fs.changed', { events: [{ kind: 'overflow', absolutePath: '/repo' }] }], - ['fs.changed', { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] }] + expect(dispatcher.fsChanged).toEqual([ + { events: [{ kind: 'create', absolutePath: '/repo/before.txt' }] }, + { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, + { events: [{ kind: 'update', absolutePath: '/repo/after-resubscribe.txt' }] } ]) }) @@ -118,12 +152,10 @@ describe('RelayFilesystemWatchRegistry', () => { expect(pool.installed).toHaveLength(2) pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) - expect(dispatcher.notify).toHaveBeenNthCalledWith(1, 'fs.changed', { - events: [{ kind: 'overflow', absolutePath: '/repo' }] - }) - expect(dispatcher.notify).toHaveBeenNthCalledWith(2, 'fs.changed', { - events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] - }) + expect(dispatcher.fsChanged).toEqual([ + { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, + { events: [{ kind: 'create', absolutePath: '/repo/recovered.txt' }] } + ]) registry.unwatch('/repo', context(1)) expect(pool.installed[1].unsubscribe).not.toHaveBeenCalled() @@ -145,17 +177,19 @@ describe('RelayFilesystemWatchRegistry', () => { ) ) - await vi.waitFor(() => expect(dispatcher.notifyClient).toHaveBeenCalledTimes(2)) - expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(1, 1, 'fs.watchFailed', { - rootPath: '/repo', - watchId: 101, - message: 'quarantine recovery failed' - }) - expect(dispatcher.notifyClient).toHaveBeenNthCalledWith(2, 2, 'fs.watchFailed', { - rootPath: '/repo', - watchId: 202, - message: 'quarantine recovery failed' - }) + await vi.waitFor(() => expect(watchFailedCalls(dispatcher)).toHaveLength(2)) + expect(watchFailedCalls(dispatcher)).toEqual([ + [ + 1, + 'fs.watchFailed', + { rootPath: '/repo', watchId: 101, message: 'quarantine recovery failed' } + ], + [ + 2, + 'fs.watchFailed', + { rootPath: '/repo', watchId: 202, message: 'quarantine recovery failed' } + ] + ]) }) it('aborts a pending crawl only after the last same-root client leaves', async () => { @@ -400,21 +434,18 @@ describe('createRelayWatcherProcessPool', () => { expect(recoveredWatches.map((watches) => watches.map(({ dir }) => dir))).toEqual( roots.map((root) => [root]) ) - expect(dispatcher.notify.mock.calls.slice(0, roots.length)).toEqual( - roots.map((root) => ['fs.changed', { events: [{ kind: 'overflow', absolutePath: root }] }]) + expect(dispatcher.fsChanged.slice(0, roots.length)).toEqual( + roots.map((root) => ({ events: [{ kind: 'overflow', absolutePath: root }] })) ) for (const watches of recoveredWatches) { const [{ callback, dir }] = watches callback(null, [{ type: 'update', path: join(dir, 'recovered.txt') }]) } - expect(dispatcher.notify.mock.calls.slice(roots.length)).toEqual( - roots.map((root) => [ - 'fs.changed', - { - events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] - } - ]) + expect(dispatcher.fsChanged.slice(roots.length)).toEqual( + roots.map((root) => ({ + events: [{ kind: 'update', absolutePath: join(root, 'recovered.txt') }] + })) ) } finally { registry.dispose() diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index 4acb786fa23..de583b2f50a 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -223,7 +223,7 @@ export class RelayFilesystemWatchRegistry { emitOverflow() return } - emitRelayWatcherEvents(this.dispatcher, state.closed, events) + emitRelayWatcherEvents(this.dispatcher, state.rootPath, state.closed, events) }, RELAY_WATCH_OPTIONS, { diff --git a/src/relay/relay-frame-decoder.ts b/src/relay/relay-frame-decoder.ts index a2c898f017e..f0466bfb87b 100644 --- a/src/relay/relay-frame-decoder.ts +++ b/src/relay/relay-frame-decoder.ts @@ -1,264 +1,32 @@ -import { - containFrameDecoderContinuation, - publishFrameDecoderError, - type DecodedFrame, - type FrameDecoderOptions -} from '../shared/relay-frame-decoder-contract' -import { RelayFrameBuffer } from '../shared/relay-frame-buffer' +import { FrameDecoder as SharedFrameDecoder } from '../shared/relay-frame-decoder' +import type { DecodedFrame, FrameDecoderOptions } from '../shared/relay-frame-decoder-contract' + +export { + HEADER_LENGTH, + MAX_MESSAGE_SIZE, + FRAME_DECODER_MAX_FRAMES_PER_TURN, + FRAME_DECODER_MAX_BYTES_PER_TURN, + FRAME_DECODER_MAX_TURN_MS, + FRAME_DECODER_MAX_RETAINED_BYTES +} from '../shared/relay-frame-decoder' export { FrameDecoderContinuationError, type DecodedFrame, type FrameDecoderOptions } from '../shared/relay-frame-decoder-contract' -export const HEADER_LENGTH = 13 -export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 -export const FRAME_DECODER_MAX_FRAMES_PER_TURN = 64 -export const FRAME_DECODER_MAX_BYTES_PER_TURN = MAX_MESSAGE_SIZE + HEADER_LENGTH -export const FRAME_DECODER_MAX_TURN_MS = 4, - FRAME_DECODER_MAX_RETAINED_BYTES = MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024 - -export class FrameDecoder { - private readonly buffer = new RelayFrameBuffer() - private oversizedPayloadBytesRemaining = 0 - private onFrame: (frame: DecodedFrame) => void - private onError: (err: Error) => void - private maxFramesPerTurn: number - private maxBytesPerTurn: number - private maxTurnMs: number - private now: () => number - private schedule: (callback: () => void) => unknown - private cancelScheduled: (handle: unknown) => void - private pause: (() => void) | null - private resume: (() => void) | null - private continuationHandle: unknown - private continuationHandleAssigned = false - private continuationScheduled = false - private paused = false - private draining = false - private generation = 0 - +// Why: the relay runs standalone on remote hosts with no renderer to surface +// decode faults, so an omitted handler must still reach stderr. +export class FrameDecoder extends SharedFrameDecoder { constructor( onFrame: (frame: DecodedFrame) => void, onError?: (err: Error) => void, options: FrameDecoderOptions = {} ) { - this.onFrame = onFrame - this.onError = onError ?? ((error) => process.stderr.write(`[relay] ${error.message}\n`)) - this.maxFramesPerTurn = positiveLimit( - options.maxFramesPerTurn, - FRAME_DECODER_MAX_FRAMES_PER_TURN + super( + onFrame, + onError ?? ((error) => process.stderr.write(`[relay] ${error.message}\n`)), + options ) - this.maxBytesPerTurn = positiveLimit(options.maxBytesPerTurn, FRAME_DECODER_MAX_BYTES_PER_TURN) - this.maxTurnMs = positiveLimit(options.maxTurnMs, FRAME_DECODER_MAX_TURN_MS) - this.now = options.now ?? Date.now - this.schedule = options.schedule ?? ((callback) => setImmediate(callback)) - this.cancelScheduled = - options.cancelScheduled ?? ((handle) => clearImmediate(handle as NodeJS.Immediate)) - this.pause = options.pause ?? null - this.resume = options.resume ?? null - } - - feed(chunk: Buffer | Uint8Array): void { - const buf = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) - const retained = this.buffer.length + buf.length - if (retained > FRAME_DECODER_MAX_RETAINED_BYTES) { - this.reset() - publishFrameDecoderError( - this.onError, - new Error(`Frame decoder retained-input limit exceeded: ${retained}`) - ) - return - } - if (buf.length > 0) { - this.buffer.append(buf) - } - if (!this.draining && !this.continuationScheduled) { - this.drainTurn() - } - } - - reset(): void { - this.generation += 1 - this.cancelContinuation() - this.buffer.clear() - this.oversizedPayloadBytesRemaining = 0 - this.releasePause() - } - - drain(): Buffer { - const out = this.buffer.drain() - this.reset() - return out - } - - private drainTurn(): void { - if (this.draining) { - return - } - this.draining = true - const generation = this.generation - const startedAt = this.now() - let frames = 0 - let bytes = 0 - - try { - while (generation === this.generation) { - if ( - frames >= this.maxFramesPerTurn || - bytes >= this.maxBytesPerTurn || - (frames > 0 && this.now() - startedAt >= this.maxTurnMs) - ) { - break - } - const discarded = this.discardOversizedPayload(bytes) - if (discarded > 0) { - bytes += discarded - continue - } - if (this.buffer.length < HEADER_LENGTH) { - break - } - const header = this.buffer.peek(HEADER_LENGTH) - const length = header.readUInt32BE(9) - if (length > MAX_MESSAGE_SIZE) { - this.buffer.discard(HEADER_LENGTH) - this.oversizedPayloadBytesRemaining = length - bytes += HEADER_LENGTH - publishFrameDecoderError( - this.onError, - new Error(`Frame payload too large: ${length} bytes — discarded`) - ) - continue - } - const totalLength = HEADER_LENGTH + length - if (this.buffer.length < totalLength) { - break - } - if (frames > 0 && bytes + totalLength > this.maxBytesPerTurn) { - break - } - const framed = this.buffer.take(totalLength) - frames += 1 - bytes += totalLength - this.onFrame({ - type: framed[0], - id: framed.readUInt32BE(1), - ack: framed.readUInt32BE(5), - payload: framed.subarray(HEADER_LENGTH, totalLength) - }) - } - } finally { - this.draining = false - } - - if (generation !== this.generation) { - return - } - if (this.hasRunnableWork()) { - this.scheduleContinuation() - } else { - this.releasePause() - } - } - - private discardOversizedPayload(bytes: number): number { - if (this.oversizedPayloadBytesRemaining === 0 || this.buffer.length === 0) { - return 0 - } - const discarded = Math.min( - this.oversizedPayloadBytesRemaining, - this.buffer.length, - Math.max(1, this.maxBytesPerTurn - bytes) - ) - this.buffer.discard(discarded) - this.oversizedPayloadBytesRemaining -= discarded - return discarded - } - - private hasRunnableWork(): boolean { - if (this.oversizedPayloadBytesRemaining > 0) { - return this.buffer.length > 0 - } - if (this.buffer.length < HEADER_LENGTH) { - return false - } - const length = this.buffer.peek(HEADER_LENGTH).readUInt32BE(9) - return length > MAX_MESSAGE_SIZE || this.buffer.length >= HEADER_LENGTH + length - } - - private scheduleContinuation(): void { - if (this.continuationScheduled) { - return - } - const generation = this.generation - this.continuationScheduled = true - try { - this.acquirePause() - } catch (error) { - this.continuationScheduled = false - throw error - } - if (generation !== this.generation) { - this.continuationScheduled = false - return - } - try { - this.continuationHandle = this.schedule(() => { - if (!this.continuationScheduled || generation !== this.generation) { - return - } - this.continuationScheduled = false - this.continuationHandleAssigned = false - this.continuationHandle = undefined - try { - this.drainTurn() - } catch (error) { - containFrameDecoderContinuation(() => this.reset(), this.onError, error) - } - }) - this.continuationHandleAssigned = true - } catch (error) { - this.continuationScheduled = false - this.continuationHandle = undefined - this.releasePause() - throw error - } - } - - private cancelContinuation(): void { - if (!this.continuationScheduled) { - return - } - this.continuationScheduled = false - if (this.continuationHandleAssigned) { - this.cancelScheduled(this.continuationHandle) - } - this.continuationHandleAssigned = false - this.continuationHandle = undefined - } - - private acquirePause(): void { - if (!this.paused) { - this.paused = true - try { - this.pause?.() - } catch (error) { - this.paused = false - throw error - } - } - } - - private releasePause(): void { - if (this.paused) { - this.paused = false - this.resume?.() - } } } - -const positiveLimit = (value: number | undefined, fallback: number): number => - value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts index 1361715a4ea..e7fee583239 100644 --- a/src/relay/relay-grace-branch.test.ts +++ b/src/relay/relay-grace-branch.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from 'vitest' -import { decideRelayGrace, type RelayGraceDecisionInput } from './relay-grace-branch' +import { describe, expect, it, vi } from 'vitest' +import { + applyRelayGraceTimeConfiguration, + decideRelayGrace, + decideRelayGraceReconfigure, + type RelayGraceBranch, + type RelayGraceDecisionInput, + type RelayGraceReconfigureInput +} from './relay-grace-branch' const EMPTY_DETACHED_STARTUP_GRACE_MS = 30_000 const IDLE_RELAY_GRACE_MS = 15 * 60_000 @@ -98,3 +105,150 @@ describe('decideRelayGrace', () => { }) }) }) + +function reconfigure(overrides: Partial = {}) { + return decideRelayGraceReconfigure({ + previousConfiguredGraceMs: 10_000, + nextConfiguredGraceMs: 86_400_000, + graceTimerArmed: true, + shutdownInFlight: false, + currentBranch: 'configured', + ...overrides + }) +} + +describe('decideRelayGraceReconfigure', () => { + it('re-arms a running window so a raised grace takes effect at the new deadline', () => { + // Why: the reported bug's call site. startGrace samples the grace at arm time, so without this + // re-arm a raise landing mid-window still fires at the old deadline. + expect(reconfigure()).toEqual({ rearm: true, retryDeferredShutdown: false }) + }) + + it('preserves shutdown-deferred across the re-arm', () => { + const rearmed = reconfigure({ nextConfiguredGraceMs: 0, currentBranch: 'shutdown-deferred' }) + + expect(rearmed).toEqual({ rearm: true, retryDeferredShutdown: true }) + expect( + decide({ + configuredGraceMs: 0, + retryDeferredShutdown: rearmed.rearm && rearmed.retryDeferredShutdown + }) + ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + }) + + it('ignores a re-assertion of the same grace', () => { + // Why: the host re-asserts its grace on every establish; re-arming on those would keep the + // window alive indefinitely. + expect(reconfigure({ previousConfiguredGraceMs: 86_400_000 })).toEqual({ rearm: false }) + }) + + it('does not arm a window that was never running', () => { + expect(reconfigure({ graceTimerArmed: false })).toEqual({ rearm: false }) + }) + + it('does not re-arm once shutdown is in flight', () => { + expect(reconfigure({ shutdownInFlight: true })).toEqual({ rearm: false }) + }) +}) + +/** + * Models relay.ts's grace state around the configureGraceTime call site: `startGrace` re-decides the + * branch exactly as the relay does, so a dropped `retryDeferredShutdown` shows up as a branch downgrade. + */ +function relayGraceHost( + overrides: { + configuredGraceMs?: number + graceTimerArmed?: boolean + shutdownInFlight?: boolean + graceBranch?: RelayGraceBranch | null + relayIdle?: boolean + activePtyCount?: number + } = {} +) { + let configuredGraceMs = overrides.configuredGraceMs ?? 10_000 + let graceBranch: RelayGraceBranch | null = overrides.graceBranch ?? 'configured' + const startGrace = vi.fn( + (_reason: string, options?: { retryDeferredShutdown?: boolean }): void => { + graceBranch = decide({ + configuredGraceMs, + relayIdle: overrides.relayIdle ?? true, + activePtyCount: overrides.activePtyCount ?? 0, + retryDeferredShutdown: options?.retryDeferredShutdown === true + }).branch + } + ) + + return { + startGrace, + branch: () => graceBranch, + configuredGraceMs: () => configuredGraceMs, + apply: (graceTimeSeconds: unknown) => + applyRelayGraceTimeConfiguration(graceTimeSeconds, { + readConfiguredGraceMs: () => configuredGraceMs, + // Mirrors PtyHandler.setGraceTimeMs's clamp so the decision sees the stored value. + writeConfiguredGraceMs: (graceMs) => { + configuredGraceMs = Math.max(0, Math.floor(graceMs)) + }, + isGraceTimerArmed: () => overrides.graceTimerArmed ?? true, + isShutdownInFlight: () => overrides.shutdownInFlight ?? false, + readGraceBranch: () => graceBranch, + startGrace + }) + } +} + +describe('applyRelayGraceTimeConfiguration', () => { + it('re-arms a deferred shutdown without downgrading it to an ordinary configured window', () => { + // Why: the call site the branch selector alone cannot cover — dropping the startGrace option here + // would leave a refused kill with nothing left to retry it. + const host = relayGraceHost({ graceBranch: 'shutdown-deferred', configuredGraceMs: 0 }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { + retryDeferredShutdown: true + }) + expect(host.branch()).toBe('shutdown-deferred') + }) + + it('re-arms an ordinary window at the raised grace', () => { + const host = relayGraceHost() + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).toHaveBeenCalledWith('grace reconfigured', { + retryDeferredShutdown: false + }) + expect(host.branch()).toBe('configured') + }) + + it('stores the host-sleep zero and lets the idle cap own the window', () => { + const host = relayGraceHost() + + expect(host.apply(0)).toEqual({ graceTimeMs: 0 }) + expect(host.startGrace).toHaveBeenCalledTimes(1) + expect(host.branch()).toBe('idle-no-ptys') + }) + + it('does not re-arm on a re-asserted grace', () => { + const host = relayGraceHost({ configuredGraceMs: 86_400_000 }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).not.toHaveBeenCalled() + }) + + it('does not arm a window that was never running', () => { + const host = relayGraceHost({ graceTimerArmed: false }) + + expect(host.apply(86_400)).toEqual({ graceTimeMs: 86_400_000 }) + expect(host.startGrace).not.toHaveBeenCalled() + }) + + it('ignores a malformed or negative payload without touching the stored grace', () => { + for (const payload of [undefined, 'later', Number.NaN, -1]) { + const host = relayGraceHost() + + expect(host.apply(payload)).toEqual({ graceTimeMs: 10_000 }) + expect(host.configuredGraceMs()).toBe(10_000) + expect(host.startGrace).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts index aa8aabcdd63..68ba3e2ec9d 100644 --- a/src/relay/relay-grace-branch.ts +++ b/src/relay/relay-grace-branch.ts @@ -23,6 +23,84 @@ export type RelayGraceDecision = { timeoutMs: number } +export type RelayGraceReconfigureInput = { + previousConfiguredGraceMs: number + nextConfiguredGraceMs: number + /** A grace window with a real deadline is running; an unlimited (deadline-less) one is not re-armed. */ + graceTimerArmed: boolean + shutdownInFlight: boolean + currentBranch: RelayGraceBranch | null +} + +export type RelayGraceReconfigureDecision = + | { rearm: false } + | { rearm: true; retryDeferredShutdown: boolean } + +/** Live relay grace state, read through accessors so the caller's `let` bindings stay authoritative. */ +export type RelayGraceTimeConfigurationInput = { + readConfiguredGraceMs: () => number + /** Writes the new grace; the value is read back so a normalizing setter wins. */ + writeConfiguredGraceMs: (graceMs: number) => void + isGraceTimerArmed: () => boolean + isShutdownInFlight: () => boolean + readGraceBranch: () => RelayGraceBranch | null + startGrace: (reason: string, options?: { retryDeferredShutdown?: boolean }) => void +} + +/** + * Applies a `relay.configureGraceTime` payload, re-arming the running window when the value changed. + * + * Why extracted from relay.ts: that file runs `main()` on import and exports nothing, so the call site + * — including the `retryDeferredShutdown` hand-off into `startGrace` — is otherwise untestable. + */ +export function applyRelayGraceTimeConfiguration( + graceTimeSeconds: unknown, + state: RelayGraceTimeConfigurationInput +): { graceTimeMs: number } { + const seconds = Number(graceTimeSeconds) + if (Number.isFinite(seconds) && seconds >= 0) { + const previousConfiguredGraceMs = state.readConfiguredGraceMs() + // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. + state.writeConfiguredGraceMs(Math.floor(seconds) * 1000) + const reconfigure = decideRelayGraceReconfigure({ + previousConfiguredGraceMs, + nextConfiguredGraceMs: state.readConfiguredGraceMs(), + graceTimerArmed: state.isGraceTimerArmed(), + shutdownInFlight: state.isShutdownInFlight(), + currentBranch: state.readGraceBranch() + }) + if (reconfigure.rearm) { + state.startGrace('grace reconfigured', { + retryDeferredShutdown: reconfigure.retryDeferredShutdown + }) + } + } + return { graceTimeMs: state.readConfiguredGraceMs() } +} + +/** + * Decides whether a live grace change must re-arm the running grace timer. + * + * Why a decision rather than inline gating: `startGrace` samples the configured grace at arm time, so + * this gate is the only thing that makes a post-launch raise take effect on an already-running window. + */ +export function decideRelayGraceReconfigure( + input: RelayGraceReconfigureInput +): RelayGraceReconfigureDecision { + // Why only on an actual change: the host re-asserts the same value on every establish, and + // restarting the window on those would keep a grace alive indefinitely. + if ( + input.nextConfiguredGraceMs === input.previousConfiguredGraceMs || + !input.graceTimerArmed || + input.shutdownInFlight + ) { + return { rearm: false } + } + // Why carry the branch forward: re-arming without it downgrades a deferred shutdown to an ordinary + // configured window, and the refused kill is never retried. + return { rearm: true, retryDeferredShutdown: input.currentBranch === 'shutdown-deferred' } +} + /** * Picks the relay's shutdown grace window. * diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index bb61e8a08af..fbbe0283045 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -13,10 +13,10 @@ import { } from './protocol' import { relayLogLine } from './relay-diagnostic-log' -// Why: client maps this exit code to a non-retryable error so it skips reconnect backoff; other non-zero exits are treated as transient. +// Why: clients treat this exit code as non-retryable; other non-zero exits are transient. export const EXIT_CODE_VERSION_MISMATCH = 42 -// Why: read .version next to the resolved script path (realpathSync, not cwd) so arbitrary-cwd or symlink-launched spawns still report a coherent version. +// Why: read .version beside the resolved script path, not the arbitrary launch cwd. export function readLaunchVersion(): string { try { const entry = process.argv[1] @@ -161,7 +161,7 @@ export type ConnectHandshakeCallbacks = { onAccepted: (leftover: Buffer) => void } -// Why: bridge-side version handshake; defense-in-depth so a bad .version can't let a v2 bridge drive a v1 daemon (cf. VS Code remoteExtensionHostAgentServer.ts:340). +// Why: defense-in-depth prevents a bad .version from pairing incompatible bridge and daemon versions. export function runConnectHandshake( sock: Socket, myVersion: string, diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts new file mode 100644 index 00000000000..41d3dd833e5 --- /dev/null +++ b/src/relay/relay-oversized-notification-survival.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' +import { encodeJsonRpcFrame, HEADER_LENGTH, parseJsonRpcMessage } from './protocol' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + type AgentHookRelayEnvelope +} from '../shared/agent-hook-relay' +import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { emitRelayWatcherEvents } from './relay-watcher-event-emitter' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { + AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, + AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, + AGENT_STATUS_MAX_SUBAGENTS, + normalizeAgentStatusPayload +} from '../shared/agent-status-types' + +// The relay runs on the REMOTE host, so the stream default is that host's Node major. +const NODE22_HWM = 64 * 1024 // Node >= 22 default +const NODE21_HWM = 16 * 1024 // Node <= 21 default + +const WATCHER_ROOT = '/home/dev/project' + +function capacityFor(hwm: number): number { + return hwm - relayWriterControlReserve(hwm) +} + +/** Largest envelope the normalizer accepts: every field at its own documented cap. */ +function buildMaximalHookEnvelope(): Record { + const payload = normalizeAgentStatusPayload({ + state: 'waiting', + prompt: 'x'.repeat(200), + interactivePrompt: 'q'.repeat(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH), + lastAssistantMessage: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH), + toolName: 'Bash', + toolInput: 'i'.repeat(160), + model: 'claude-opus-5', + agentType: 'general-purpose', + subagents: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS }, (_, i) => ({ + id: `subagent-${i}`.padEnd(64, '0'), + state: 'working', + agentType: 'general-purpose', + model: 'claude-opus-5', + toolName: 'Grep', + toolInput: 'i'.repeat(160) + })) + }) + expect(payload, 'normalizer must accept this payload').not.toBeNull() + return { + paneKey: 'worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d', + connectionId: 'ssh-target-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'Notification', + payload + } as unknown as Record +} + +function watcherProcessEvents(count: number): WatcherProcessEvent[] { + return Array.from({ length: count }, (_, i) => ({ + type: 'update', + path: `${WATCHER_ROOT}/node_modules/.vite/deps/chunk-${String(i).padStart(6, '0')}.js` + })) as unknown as WatcherProcessEvent[] +} + +/** The exact `fs.changed` params emitRelayWatcherEvents publishes, so the pinned sizes measure reality. */ +function buildWatcherBatch(count: number): Record { + return { + events: watcherProcessEvents(count).map((event) => ({ + kind: event.type, + absolutePath: event.path + })) + } +} + +function frameBytes(method: string, params: Record): number { + return encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, 1, 0).length +} + +function frameParams(frame: Buffer): Record { + const payloadLength = frame.readUInt32BE(9) + const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) + return 'method' in message ? ((message.params ?? {}) as Record) : {} +} + +function createDispatcher(hwm: number, onClose: () => void, sink: Buffer[]): RelayDispatcher { + return new RelayDispatcher( + (data: Buffer) => { + sink.push(data) + return true + }, + { + writableHighWaterMark: () => hwm, + writableLength: () => 0, + supportsWriteCallback: false, + close: onClose + } + ) +} + +describe('relay oversized notification survival', () => { + // Without this the byte measurements below could describe a payload the emitter never sends. + it('measures the params emitRelayWatcherEvents actually publishes', () => { + const sink: Buffer[] = [] + const dispatcher = createDispatcher(NODE22_HWM, () => {}, sink) + + try { + emitRelayWatcherEvents(dispatcher, WATCHER_ROOT, false, watcherProcessEvents(1)) + expect(sink).toHaveLength(1) + expect(frameParams(sink[0])).toEqual(buildWatcherBatch(1)) + } finally { + dispatcher.dispose() + } + }) + + it('measures both producers against both Node tiers', () => { + const hookBytes = frameBytes(AGENT_HOOK_NOTIFICATION_METHOD, buildMaximalHookEnvelope()) + const fullBatchBytes = frameBytes('fs.changed', buildWatcherBatch(MAX_BATCHED_WATCHER_EVENTS)) + + // Binary-search the largest watcher batch that still fits each tier. + const trip = (cap: number): number => { + let lo = 1 + let hi = MAX_BATCHED_WATCHER_EVENTS + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (frameBytes('fs.changed', buildWatcherBatch(mid)) <= cap) { + lo = mid + } else { + hi = mid - 1 + } + } + return lo + } + + // Capacity is a fixed fraction of the sink's high-water mark, so these are exact. + expect(capacityFor(NODE22_HWM)).toBe(49152) + expect(capacityFor(NODE21_HWM)).toBe(12288) + + // agent.hook: fits a modern remote, over-cap on an older one — which is why the remote's + // Node major, not Orca's, decides whether a maximal envelope has to shed. + expect(hookBytes).toBeLessThan(capacityFor(NODE22_HWM)) + expect(hookBytes).toBeGreaterThan(capacityFor(NODE21_HWM)) + + // fs.changed: the batch cap is orders of magnitude above what one frame can carry, on BOTH tiers. + expect(fullBatchBytes).toBeGreaterThan(capacityFor(NODE22_HWM)) + expect(trip(capacityFor(NODE22_HWM)) * 5).toBeLessThan(MAX_BATCHED_WATCHER_EVENTS) + expect(trip(capacityFor(NODE21_HWM))).toBeLessThan(trip(capacityFor(NODE22_HWM))) + }) + + it('a 5000-event fs.changed no longer closes the client on a Node>=22-sized sink', () => { + const sink: Buffer[] = [] + let closes = 0 + const dispatcher = createDispatcher(NODE22_HWM, () => (closes += 1), sink) + + try { + dispatcher.notify('pty.exit', { id: 'pty-1', exitCode: 0 }) + const before = sink.length + expect(before).toBeGreaterThan(0) + + // The batch the registry is configured to allow (maxEventsPerBatch = MAX_BATCHED_WATCHER_EVENTS). + emitRelayWatcherEvents( + dispatcher, + WATCHER_ROOT, + false, + watcherProcessEvents(MAX_BATCHED_WATCHER_EVENTS) + ) + expect(closes).toBe(0) + expect(sink.length).toBeGreaterThan(before) + expect(sink.every((frame) => frame.length <= capacityFor(NODE22_HWM))).toBe(true) + + // The link is still live: the next frame reaches the sink. + const afterBatch = sink.length + dispatcher.notify('pty.exit', { id: 'pty-2', exitCode: 0 }) + expect(sink.length).toBe(afterBatch + 1) + } finally { + dispatcher.dispose() + } + }) + + it('a maximal agent.hook envelope survives a Node<=21-sized sink through publishAgentHookEnvelope', () => { + const sink: Buffer[] = [] + let closes = 0 + const dispatcher = createDispatcher(NODE21_HWM, () => (closes += 1), sink) + + try { + publishAgentHookEnvelope( + dispatcher, + buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope + ) + + expect(closes).toBe(0) + expect(sink).toHaveLength(1) + expect(sink[0].length).toBeLessThanOrEqual(capacityFor(NODE21_HWM)) + + const params = frameParams(sink[0]) + expect(params.paneKey).toBe('worktree-abc:0f2c1d84-3b9a-4c77-9d21-5e6f7a8b9c0d') + expect(params.connectionId).toBe('ssh-target-1') + const payload = params.payload as Record + expect(payload.state).toBe('waiting') + expect(payload.lastAssistantMessage).toBeUndefined() + } finally { + dispatcher.dispose() + } + }) + + it("publishAgentHookEnvelope does not mutate the caller's envelope", () => { + const sink: Buffer[] = [] + const dispatcher = createDispatcher(NODE21_HWM, () => {}, sink) + const envelope = buildMaximalHookEnvelope() + const before = structuredClone(envelope) + + try { + publishAgentHookEnvelope(dispatcher, envelope as unknown as AgentHookRelayEnvelope) + // The hook server replays this exact object after --connect, so shedding must never touch it. + expect(envelope).toEqual(before) + } finally { + dispatcher.dispose() + } + }) + + it('reattach + replay does not re-kill', () => { + const sink: Buffer[] = [] + let killCloses = 0 + let retiredSinkCloses = 0 + let replacingSink = false + // setWrite deliberately closes the sink it retires; only a close on the LIVE sink is a kill. + const onClose = (): void => { + if (replacingSink) { + retiredSinkCloses += 1 + } else { + killCloses += 1 + } + } + const dispatcher = createDispatcher(NODE21_HWM, onClose, sink) + const envelope = buildMaximalHookEnvelope() as unknown as AgentHookRelayEnvelope + + try { + publishAgentHookEnvelope(dispatcher, envelope) + expect(sink).toHaveLength(1) + + for (let reconnect = 0; reconnect < 4; reconnect += 1) { + // Real reattach: relay.ts swaps the sink onto the new SSH channel, then the + // hook server replays its per-paneKey cache (agent-hook-server.ts). + replacingSink = true + dispatcher.setWrite( + (data: Buffer) => { + sink.push(data) + return true + }, + { + writableHighWaterMark: () => NODE21_HWM, + writableLength: () => 0, + supportsWriteCallback: false, + close: onClose + } + ) + replacingSink = false + publishAgentHookEnvelope(dispatcher, envelope) + } + + expect(killCloses).toBe(0) + expect(retiredSinkCloses).toBe(4) + expect(sink).toHaveLength(5) + } finally { + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/relay-watcher-event-emitter.test.ts b/src/relay/relay-watcher-event-emitter.test.ts index ae5781c5745..acf734acd71 100644 --- a/src/relay/relay-watcher-event-emitter.test.ts +++ b/src/relay/relay-watcher-event-emitter.test.ts @@ -1,51 +1,867 @@ -import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process-protocol' import { RelayDispatcher } from './dispatcher' +import { + DISPATCHER_CONTROL_QUEUE_MAX_BYTES, + DEFAULT_PRODUCER_QUEUE_MAX_BYTES, + DISPATCHER_CONTROL_QUEUE_MAX_FRAMES +} from './dispatcher-writer-admission' +import type { RelayClientSinkOptions, RelayClientWrite } from './dispatcher-writer-sink' +import { LEGACY_CLIENT_RETAINED_BYTES_LOW } from './legacy-relay-publication-ledger' import { HEADER_LENGTH, parseJsonRpcMessage } from './protocol' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -function frameMethod(frame: Buffer): string { +type WatcherFrameEvent = { kind: string; absolutePath: string; isDirectory?: boolean } + +function frameMessage(frame: Buffer): { method: string; params?: Record } { const payloadLength = frame.readUInt32BE(9) const message = parseJsonRpcMessage(frame.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)) - return 'method' in message ? message.method : '' + return 'method' in message + ? { method: message.method, params: message.params as Record | undefined } + : { method: '' } +} + +function frameMethod(frame: Buffer): string { + return frameMessage(frame).method +} + +function frameEvents(frame: Buffer): WatcherFrameEvent[] { + const params = frameMessage(frame).params + return (params?.events ?? []) as WatcherFrameEvent[] +} + +function createRecordingSink(highWaterMark?: number): { + frames: Buffer[] + closes: () => number + drainWaiters: (() => void)[] + blockNextWrite: () => void + write: RelayClientWrite + options: RelayClientSinkOptions +} { + const frames: Buffer[] = [] + const drainWaiters: (() => void)[] = [] + let closes = 0 + let blocked = false + const write: RelayClientWrite = (frame) => { + frames.push(Buffer.from(frame)) + if (blocked) { + blocked = false + return false + } + return true + } + const options: RelayClientSinkOptions = { + ...(highWaterMark === undefined ? {} : { writableHighWaterMark: () => highWaterMark }), + close: () => { + closes += 1 + }, + waitWriteDrain: (callback) => { + drainWaiters.push(callback) + return () => { + const index = drainWaiters.indexOf(callback) + if (index >= 0) { + drainWaiters.splice(index, 1) + } + } + } + } + return { + frames, + closes: () => closes, + drainWaiters, + blockNextWrite: () => { + blocked = true + }, + write, + options + } +} + +// A sink that acknowledges writes on demand, so frames can be held in flight one lane at a time. +function createCallbackSink(): { + frames: Buffer[] + settle: (index: number) => void + write: RelayClientWrite + options: RelayClientSinkOptions +} { + const frames: Buffer[] = [] + const settlements: ((result: { ok: true }) => void)[] = [] + const write: RelayClientWrite = (frame, onSettled) => { + frames.push(Buffer.from(frame)) + settlements.push(onSettled) + return true + } + return { + frames, + settle: (index) => settlements[index]?.({ ok: true }), + write, + options: { supportsWriteCallback: true, close: () => {} } + } +} + +function watcherBatch(count: number): { type: 'create'; path: string; isDirectory: false }[] { + return Array.from({ length: count }, (_unused, index) => ({ + type: 'create' as const, + path: `/workspace/project/src/module-${index}/component-${index}.tsx`, + isDirectory: false as const + })) +} + +function parentDir(absolutePath: string): string { + return absolutePath.slice(0, absolutePath.lastIndexOf('/')) +} + +// Fills the 2 MB producer queue so every subsequent watcher batch is rejected on the producer lane. +function saturateProducerQueue(dispatcher: RelayDispatcher): void { + let admitted = 0 + for (const chunk of [40_000, 1_000, 1]) { + while ( + admitted < 5_000 && + dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x'.repeat(chunk) }) + ) { + admitted += 1 + } + } + expect(admitted).toBeGreaterThan(0) + expect(admitted).toBeLessThan(5_000) +} + +// Parks PTY frames in the producer queue of a stalled sink without filling it. +function parkProducerBytes(dispatcher: RelayDispatcher, bytes: number): number { + const chunk = 'x'.repeat(40_000) + let parked = 0 + while (parked < bytes && dispatcher.tryNotifyPtyData({ paneId: 'pane', data: chunk })) { + parked += chunk.length + } + expect(parked).toBeGreaterThanOrEqual(bytes) + return parked +} + +// Parks PTY frames in ONE client's producer queue, leaving every peer's retention untouched. +function parkProducerBytesForClient( + dispatcher: RelayDispatcher, + clientId: number, + bytes: number +): number { + const chunk = 'x'.repeat(40_000) + let parked = 0 + while ( + parked < bytes && + dispatcher.publishProducerNotification(clientId, 'pty.data', { paneId: 'pane', data: chunk }) + ) { + parked += chunk.length + } + expect(parked).toBeGreaterThanOrEqual(bytes) + return parked +} + +function isOverflowFrame(frame: Buffer): boolean { + return frameEvents(frame).some((event) => event.kind === 'overflow') +} + +function watcherFrames(frames: readonly Buffer[]): Buffer[] { + return frames.filter((frame) => frameMethod(frame) === 'fs.changed') +} + +function deliveredEvents(events: readonly WatcherProcessEvent[]): WatcherFrameEvent[] { + return events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) } describe('relay watcher writer admission', () => { - it('keeps watcher batches on the bounded ordinary lane under saturation', () => { - const frames: Buffer[] = [] - const drainWaiters: (() => void)[] = [] - let saturate = true - const dispatcher = new RelayDispatcher( - (frame) => { - frames.push(Buffer.from(frame)) - if (saturate) { - saturate = false - return false - } - return true - }, - { - waitWriteDrain: (callback) => { - drainWaiters.push(callback) - return () => { - const index = drainWaiters.indexOf(callback) - if (index >= 0) { - drainWaiters.splice(index, 1) - } - } - } - } - ) + it('keeps watcher batches on the producer lane while overflow markers ride the control lane', () => { + const sink = createRecordingSink() + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) try { - emitRelayWatcherEvents(dispatcher, false, [ + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ { type: 'create', path: '/workspace/first', isDirectory: false } ]) emitRelayWatcherOverflow(dispatcher, '/workspace', false) dispatcher.notifyClient(1, 'control.event') - expect(frames.map(frameMethod)).toEqual(['fs.changed']) - drainWaiters.shift()?.() - expect(frames.map(frameMethod)).toEqual(['fs.changed', 'control.event', 'fs.changed']) + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) + sink.drainWaiters.shift()?.() + // The marker now shares the control lane with control.event and drains ahead of the producer lanes. + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed', 'fs.changed', 'control.event']) + expect(frameEvents(sink.frames[1])).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('relay watcher overflow suppression key', () => { + it('keeps the source free of NUL bytes so git and grep still see text', () => { + const source = readFileSync( + fileURLToPath(new URL('./relay-watcher-event-emitter.ts', import.meta.url)) + ) + expect(source.includes(0)).toBe(false) + }) + + it('scopes the outstanding marker per client as well as per root', () => { + const primary = createRecordingSink(65536) + const secondary = createRecordingSink(65536) + primary.blockNextWrite() + secondary.blockNextWrite() + const dispatcher = new RelayDispatcher(primary.write, primary.options) + + try { + dispatcher.attachClient(secondary.write, secondary.options) + // Two clients, same root: a key that collapses them would silently desync the second tree. + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + primary.drainWaiters.shift()?.() + secondary.drainWaiters.shift()?.() + + for (const sink of [primary, secondary]) { + expect(sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed')).toHaveLength(1) + expect(sink.closes()).toBe(0) + } + } finally { + dispatcher.dispose() + } + }) + + it('drops the marker instead of retaining it when the client is already gone', () => { + const primary = createRecordingSink(65536) + const secondary = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const secondaryId = dispatcher.attachClient(secondary.write, secondary.options) + dispatcher.detachClient(secondaryId) + // The race the emitter has to survive: the id list was sampled before the client went away. + const activeIds = vi.spyOn(dispatcher, 'activeClientIds').mockReturnValue([secondaryId]) + const notify = vi.spyOn(dispatcher, 'tryNotifyClient') + + try { + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + // A second attempt proves nothing was retained: a removed client has no tree left to resync. + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(notify).toHaveBeenCalledTimes(2) + expect(watcherFrames(secondary.frames)).toHaveLength(0) + } finally { + notify.mockRestore() + activeIds.mockRestore() + dispatcher.dispose() + } + }) + + it('republishes an admitted marker the replaced sink never wrote', () => { + const stalled = createRecordingSink(65536) + const replacement = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + // Saturates the sink without filling the control queue, so the marker is admitted and then parked. + dispatcher.notifyClient(clientId, 'control.warmup') + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(stalled.frames)).toHaveLength(0) + + // setWrite fails every queued frame; an admitted-but-unwritten marker is as lost as a rejected one. + dispatcher.setWrite(replacement.write, replacement.options) + + expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) + + it('republishes an admitted marker when invalidateClient precedes the sink replacement', () => { + const stalled = createRecordingSink(65536) + const replacement = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + dispatcher.notifyClient(clientId, 'control.warmup') + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(stalled.frames)).toHaveLength(0) + + // A dropped SSH channel invalidates the primary — a detach that keeps the id, so the marker the + // failed settlement retains must survive it and ride out on the reconnected sink. + dispatcher.invalidateClient() + dispatcher.setWrite(replacement.write, replacement.options) + + expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) + + it('retries a marker rejected by a full control queue once capacity returns', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(sink.frames)).toHaveLength(0) + expect(sink.closes()).toBe(0) + + // No second emit: the watcher has no further trigger, so the retry must come from the retained marker. + sink.drainWaiters.shift()?.() + expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('leaves a retained marker alone until the control lane actually frees a slot', () => { + const sink = createCallbackSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x' }) + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(sink.frames)).toHaveLength(0) + + const notify = vi.spyOn(dispatcher, 'tryNotifyClient') + // Capacity fires for every lane: a producer settlement frees no control slot, so re-encoding + // the marker here could only be rejected again. + sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'pty.data')) + expect(notify).not.toHaveBeenCalled() + + // A control settlement does free one, and the retained marker goes out on it. + sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'control.0')) + expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + notify.mockRestore() + } finally { + dispatcher.dispose() + } + }) + + it('waits until the control byte budget can admit the retained marker', () => { + const sink = createCallbackSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + const markerParams = { + events: [{ kind: 'overflow', absolutePath: '/workspace' }] + } + const markerBytes = dispatcher.notificationFrameBytes('fs.changed', markerParams) + const emptyFillBytes = dispatcher.notificationFrameBytes('control.byte-fill', { data: '' }) + const fillDataBytes = DISPATCHER_CONTROL_QUEUE_MAX_BYTES - markerBytes + 1 - emptyFillBytes + + dispatcher.tryNotifyPtyData({ paneId: 'pane', data: 'x' }) + dispatcher.notifyClient(clientId, 'control.byte-fill', { data: 'x'.repeat(fillDataBytes) }) + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(sink.frames)).toHaveLength(0) + + const notify = vi.spyOn(dispatcher, 'tryNotifyClient') + sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'pty.data')) + expect(notify).not.toHaveBeenCalled() + + sink.settle(sink.frames.findIndex((frame) => frameMethod(frame) === 'control.byte-fill')) + expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + notify.mockRestore() + } finally { + dispatcher.dispose() + } + }) + + it('keeps a retained marker per root and sends each exactly once', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + + for (let index = 0; index < 3; index += 1) { + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + emitRelayWatcherOverflow(dispatcher, '/other', false) + } + sink.drainWaiters.shift()?.() + + // A retained root must neither duplicate nor suppress a peer root's resync. + expect(watcherFrames(sink.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' }, + { kind: 'overflow', absolutePath: '/other' } + ]) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('republishes a retained marker against a replaced primary sink', () => { + const stalled = createRecordingSink(65536) + const replacement = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(stalled.frames)).toHaveLength(0) + + // setWrite replaces the sink without a detach, so the marker must follow the client, not the writer. + dispatcher.setWrite(replacement.write, replacement.options) + + expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) + + it('republishes a retained marker when invalidateClient precedes the sink replacement', () => { + const stalled = createRecordingSink(65536) + const replacement = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + + try { + const clientId = dispatcher.activeClientIds()[0] + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(clientId, `control.${index}`) + } + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(stalled.frames)).toHaveLength(0) + + // The SSH reconnect order: invalidateClient detaches the primary without retiring the id, so the + // pending marker must survive that detach and ride out on the replacement sink. + dispatcher.invalidateClient() + dispatcher.setWrite(replacement.write, replacement.options) + + expect(watcherFrames(replacement.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) + + it('forgets a retained marker when the client detaches', () => { + const primary = createRecordingSink(65536) + const secondary = createRecordingSink(65536) + secondary.blockNextWrite() + const dispatcher = new RelayDispatcher(primary.write, primary.options) + + try { + const secondaryId = dispatcher.attachClient(secondary.write, secondary.options) + for (let index = 0; index < DISPATCHER_CONTROL_QUEUE_MAX_FRAMES; index += 1) { + dispatcher.notifyClient(secondaryId, `control.${index}`) + } + emitRelayWatcherOverflow(dispatcher, '/workspace', false) + expect(watcherFrames(secondary.frames)).toHaveLength(0) + + dispatcher.detachClient(secondaryId) + secondary.drainWaiters.shift()?.() + + expect(watcherFrames(secondary.frames)).toHaveLength(0) + // The primary's own marker went out on its first attempt and stays unaffected. + expect(watcherFrames(primary.frames).flatMap(frameEvents)).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + } finally { + dispatcher.dispose() + } + }) +}) + +describe('relay watcher batch chunking', () => { + it.each([ + [65536, 49152], + [16384, 12288] + ])('splits a 5000-event batch into frames that fit a %i-byte sink', (highWaterMark, capacity) => { + const sink = createRecordingSink(highWaterMark) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const events = watcherBatch(5000) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + expect(sink.frames.every((frame) => frameMethod(frame) === 'fs.changed')).toBe(true) + expect(sink.frames.every((frame) => frame.length <= capacity)).toBe(true) + expect(sink.frames.flatMap(frameEvents)).toEqual( + events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) + ) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('encodes the empty envelope once instead of re-encoding candidate chunks', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const budget = vi.spyOn(dispatcher, 'producerEnvelopeBudget') + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + expect(budget).toHaveBeenCalledTimes(1) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(5000) + } finally { + budget.mockRestore() + dispatcher.dispose() + } + }) + + it('sizes each event once across clients that need different chunk cuts', () => { + const primary = createRecordingSink(16384) + const secondary = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(primary.write, primary.options) + const stringify = vi.spyOn(JSON, 'stringify') + const events = watcherBatch(5000) + + try { + dispatcher.attachClient(secondary.write, secondary.options) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const sizedEvents = stringify.mock.calls.filter(([value]) => { + if (typeof value !== 'object' || value === null) { + return false + } + return 'kind' in value && 'absolutePath' in value + }) + expect(sizedEvents).toHaveLength(events.length) + expect(primary.frames.flatMap(frameEvents)).toHaveLength(events.length) + expect(secondary.frames.flatMap(frameEvents)).toHaveLength(events.length) + } finally { + stringify.mockRestore() + dispatcher.dispose() + } + }) + + it('falls back to one overflow marker when a single event exceeds the frame budget', () => { + const sink = createRecordingSink(65536) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'update', path: `/workspace/${'p'.repeat(60_000)}`, isDirectory: false } + ]) + + expect(sink.frames.map(frameMethod)).toEqual(['fs.changed']) + expect(frameEvents(sink.frames[0])).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('emits the overflow marker on the control lane when a chunk is rejected by a full producer queue', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + // Fill the 2 MB producer queue down to a residue smaller than a one-event fs.changed frame. + saturateProducerQueue(dispatcher) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: '/workspace/first', isDirectory: false } + ]) + sink.drainWaiters.shift()?.() + + const markers = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') + expect(markers).toHaveLength(1) + expect(frameEvents(markers[0])).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) + // Control lane preempts: the marker lands before the producer frames still queued behind it. + expect(sink.frames.indexOf(markers[0])).toBeLessThan(sink.frames.length - 1) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('keeps at most one outstanding overflow marker per root without closing the client', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + saturateProducerQueue(dispatcher) + + // More rejected batches than the 256-frame control queue can hold: one marker each would close the link. + for (let index = 0; index < 300; index += 1) { + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: `/workspace/file-${index}`, isDirectory: false } + ]) + } + // A second root must still get its own marker, or that tree silently desyncs. + emitRelayWatcherEvents(dispatcher, '/other', false, [ + { type: 'create', path: '/other/file', isDirectory: false } + ]) + expect(sink.closes()).toBe(0) + + sink.drainWaiters.shift()?.() + const markers = sink.frames + .filter((frame) => frameMethod(frame) === 'fs.changed') + .flatMap(frameEvents) + expect(markers).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' }, + { kind: 'overflow', absolutePath: '/other' } + ]) + expect(sink.closes()).toBe(0) + + // The slot clears once the marker drains, so a later batch can still resync the same root. + emitRelayWatcherEvents(dispatcher, '/workspace', false, [ + { type: 'create', path: '/workspace/later', isDirectory: false } + ]) + expect( + sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed').flatMap(frameEvents) + ).toHaveLength(3) + } finally { + dispatcher.dispose() + } + }) + + it('keeps one directory in one chunk when the batch interleaves directories', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const directoryCount = 8 + const replacedPath = '/workspace/project/src/module-7/component-0.tsx' + const events: WatcherProcessEvent[] = [ + ...Array.from({ length: 200 }, (_unused, index) => ({ + type: 'create' as const, + path: `/workspace/project/src/module-${index % directoryCount}/component-${Math.floor(index / directoryCount)}.tsx`, + isDirectory: false + })), + { type: 'delete' as const, path: replacedPath } + ] + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + const frameDirs = sink.frames.map( + (frame) => new Set(frameEvents(frame).map((event) => parentDir(event.absolutePath))) + ) + const dirs = Array.from(new Set(events.map((event) => parentDir(event.path)))) + expect(dirs).toHaveLength(directoryCount) + // Each directory in one frame; the renderer dedupes refreshes per payload, so extra frames = extra readDir RPCs. + const refreshes = frameDirs.reduce((total, frameDir) => total + frameDir.size, 0) + expect(refreshes).toBeLessThanOrEqual(directoryCount + sink.frames.length - 1) + + const delivered = sink.frames.flatMap(frameEvents) + for (const dir of dirs) { + const positions = delivered + .map((event, index) => ({ event, index })) + .filter((entry) => parentDir(entry.event.absolutePath) === dir) + .map((entry) => entry.index) + expect(positions.at(-1)! - positions[0]).toBe(positions.length - 1) + } + // Stable grouping keeps per-path order: the create still precedes the delete of the same path. + expect( + delivered.filter((event) => event.absolutePath === replacedPath).map((event) => event.kind) + ).toEqual(['create', 'delete']) + expect(delivered).toHaveLength(events.length) + } finally { + dispatcher.dispose() + } + }) + + it('does not report a drop for a batch it goes on to deliver in full', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + try { + const events = watcherBatch(5000) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + expect(sink.frames.length).toBeGreaterThan(1) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) + // A "Dropped fs.changed" line here would both lie and burn the one-per-generation + // suppression slot a genuine over-capacity drop needs. + const lines = stderr.mock.calls.map(([line]) => String(line)) + expect(lines.filter((line) => line.includes('fs.changed'))).toEqual([]) + } finally { + stderr.mockRestore() + dispatcher.dispose() + } + }) + + it('sizes chunks near the byte minimum instead of overshooting it', () => { + const sink = createRecordingSink(16384) + const capacity = 12288 + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + + const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) + expect(sink.frames.length).toBeLessThanOrEqual(Math.ceil(totalBytes / capacity) + 1) + for (const frame of sink.frames.slice(0, -1)) { + expect(frame.length).toBeGreaterThanOrEqual(capacity * 0.9) + } + } finally { + dispatcher.dispose() + } + }) + + it('stops the chunk walk while the producer queue sits past its retention reserve', () => { + const sink = createRecordingSink(65536) + sink.blockNextWrite() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + + try { + // Past the retention reserve but far from the 2 MB queue limit: every chunk would still be admitted. + const parked = parkProducerBytes(dispatcher, LEGACY_CLIENT_RETAINED_BYTES_LOW) + expect(parked).toBeLessThan(DEFAULT_PRODUCER_QUEUE_MAX_BYTES / 2 + 256 * 1024) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(5000)) + sink.drainWaiters.shift()?.() + + const watcherFrames = sink.frames.filter((frame) => frameMethod(frame) === 'fs.changed') + expect(watcherFrames.map(isOverflowFrame)).toEqual([true]) + // The flood injected nothing: the interactive lane keeps the whole reserve instead of ~425 KB less. + const injected = watcherFrames + .filter((frame) => !isOverflowFrame(frame)) + .reduce((total, frame) => total + frame.length, 0) + expect(injected).toBe(0) + expect(injected).toBeLessThan( + DEFAULT_PRODUCER_QUEUE_MAX_BYTES - LEGACY_CLIENT_RETAINED_BYTES_LOW + ) + expect(sink.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('delivers a whole flood to a healthy client while a second client sits past its reserve', () => { + const stalled = createRecordingSink(65536) + const healthy = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(stalled.write, stalled.options) + const events = watcherBatch(5000) + + try { + dispatcher.attachClient(healthy.write, healthy.options) + const stalledId = dispatcher.activeClientIds()[0] + parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + stalled.drainWaiters.shift()?.() + + // A resync forces a readDir per directory, so an unrelated peer's stall must never trigger one here. + expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) + expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) + expect(watcherFrames(stalled.frames).map(isOverflowFrame)).toEqual([true]) + expect(healthy.closes()).toBe(0) + expect(stalled.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('stops the walk for the congested client while a healthy peer takes the whole flood', () => { + const healthy = createRecordingSink(65536) + const stalled = createRecordingSink(65536) + stalled.blockNextWrite() + const dispatcher = new RelayDispatcher(healthy.write, healthy.options) + const events = watcherBatch(5000) + + try { + // Reversed attach order from the peer-stall case: the gate must read the client being written to. + const stalledId = dispatcher.attachClient(stalled.write, stalled.options) + parkProducerBytesForClient(dispatcher, stalledId, LEGACY_CLIENT_RETAINED_BYTES_LOW) + + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + stalled.drainWaiters.shift()?.() + + const stalledWatcher = watcherFrames(stalled.frames) + expect(stalledWatcher.map(isOverflowFrame)).toEqual([true]) + const injected = stalledWatcher + .filter((frame) => !isOverflowFrame(frame)) + .reduce((total, frame) => total + frame.length, 0) + // Nothing injected: the congested client keeps its whole reserve for interactive PTY frames. + expect(injected).toBe(0) + expect(watcherFrames(healthy.frames).some(isOverflowFrame)).toBe(false) + expect(watcherFrames(healthy.frames).flatMap(frameEvents)).toEqual(deliveredEvents(events)) + expect(healthy.closes()).toBe(0) + expect(stalled.closes()).toBe(0) + } finally { + dispatcher.dispose() + } + }) + + it('still delivers a flood larger than the reserve while the sink keeps draining', () => { + const sink = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const events = watcherBatch(20_000) + + try { + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const totalBytes = sink.frames.reduce((total, frame) => total + frame.length, 0) + expect(totalBytes).toBeGreaterThan(LEGACY_CLIENT_RETAINED_BYTES_LOW) + expect(sink.frames.some(isOverflowFrame)).toBe(false) + expect(sink.frames.flatMap(frameEvents)).toHaveLength(events.length) + } finally { + dispatcher.dispose() + } + }) + + it('chunks per client so a small sink never degrades a healthy one', () => { + const healthy = createRecordingSink(65536) + const small = createRecordingSink(16384) + const dispatcher = new RelayDispatcher(healthy.write, healthy.options) + + try { + dispatcher.attachClient(small.write, small.options) + const events = watcherBatch(200) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) + + const expected = events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + isDirectory: event.isDirectory + })) + expect(healthy.frames).toHaveLength(1) + expect(frameEvents(healthy.frames[0])).toEqual(expected) + expect(small.frames.length).toBeGreaterThan(1) + expect(small.frames.every((frame) => frame.length <= 12288)).toBe(true) + expect(small.frames.flatMap(frameEvents)).toEqual(expected) + expect(healthy.closes()).toBe(0) + expect(small.closes()).toBe(0) } finally { dispatcher.dispose() } diff --git a/src/relay/relay-watcher-event-emitter.ts b/src/relay/relay-watcher-event-emitter.ts index 05cdd68c281..6bfef6a97f0 100644 --- a/src/relay/relay-watcher-event-emitter.ts +++ b/src/relay/relay-watcher-event-emitter.ts @@ -1,52 +1,310 @@ import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process' +import { resolveRuntimePath } from '../shared/cross-platform-path' import type { RelayDispatcher } from './dispatcher' -const FS_CHANGED = 'fs.changed' - -type RelayWatcherEventPayload = { +type MappedWatcherEvent = { kind: string absolutePath: string isDirectory?: boolean } +type WatcherBatchSizing = { + eventBytes: Map + batchBytes: number +} + +type OverflowMarkerState = { + // Why: one outstanding marker per (client, root) keeps sustained backpressure bounded. + inFlight: Set + // Rejected markers, retained per client so they can be republished when the control lane frees up. + pending: Map> + capacityUnsubscribes: Map void> +} + +const overflowMarkerStates = new WeakMap() + export function emitRelayWatcherEvents( dispatcher: RelayDispatcher, + rootPath: string, closed: boolean, events: readonly WatcherProcessEvent[] ): void { if (closed || events.length === 0) { return } - const frameCapacity = dispatcher.broadcastProducerFrameCapacity() - if (frameCapacity === undefined) { + const mapped: MappedWatcherEvent[] = events.map((event) => ({ + kind: event.type, + absolutePath: event.path, + ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + })) + // Grouping walks every path, so only the chunking path pays for it — and only once across all clients. + let grouped: MappedWatcherEvent[] | null = null + const groupedByDirectory = (): MappedWatcherEvent[] => + (grouped ??= groupWatcherEventsByDirectory(mapped)) + let sizing: WatcherBatchSizing | null = null + const batchSizing = (): WatcherBatchSizing => (sizing ??= measureWatcherBatch(mapped)) + for (const clientId of dispatcher.activeClientIds()) { + publishWatcherBatchToClient( + dispatcher, + clientId, + rootPath, + mapped, + groupedByDirectory, + batchSizing + ) + } +} + +/** + * Why: the renderer dedupes directory refreshes within a SINGLE fs.changed payload, so a directory + * scattered across chunks costs one forced readDir RPC per chunk. Grouping is stable, so events for a + * given path keep their relative order and a create-then-delete can never invert. + */ +function groupWatcherEventsByDirectory( + mapped: readonly MappedWatcherEvent[] +): MappedWatcherEvent[] { + const groups = new Map() + for (const event of mapped) { + // Runs on the remote host: derive the parent with the runtime-flavored resolver, not a '/' split. + const parentPath = resolveRuntimePath(event.absolutePath, '..') + const group = groups.get(parentPath) + if (group) { + group.push(event) + } else { + groups.set(parentPath, [event]) + } + } + return Array.from(groups.values()).flat() +} + +function encodedWatcherEventBytes(event: MappedWatcherEvent): number { + return Buffer.byteLength(JSON.stringify(event)) +} + +function measureWatcherBatch(mapped: readonly MappedWatcherEvent[]): WatcherBatchSizing { + const eventBytes = new Map() + let batchBytes = Math.max(0, mapped.length - 1) + for (const event of mapped) { + const bytes = encodedWatcherEventBytes(event) + eventBytes.set(event, bytes) + batchBytes += bytes + } + return { eventBytes, batchBytes } +} + +// Batches are sized to each sink's frame capacity; a batch that cannot be sized degrades to an overflow resync. +function publishWatcherBatchToClient( + dispatcher: RelayDispatcher, + clientId: number, + rootPath: string, + mapped: readonly MappedWatcherEvent[], + groupedByDirectory: () => readonly MappedWatcherEvent[], + batchSizing: () => WatcherBatchSizing +): void { + const publish = (events: readonly MappedWatcherEvent[]): boolean => + dispatcher.publishProducerNotification(clientId, 'fs.changed', { events }) + + // Fast path: publish the whole batch first — two encodes, the same cost as an unchunked emit. + // logDrop:false because rejection here is a measurement, not an outcome: the batch is re-sent in + // chunks below, so logging it would report a drop for events that all arrive. + if ( + dispatcher.publishProducerNotification( + clientId, + 'fs.changed', + { events: mapped }, + { + logDrop: false + } + ) + ) { return } - // Whole watcher batches exceed the ordinary-lane cap; byte-pack ordered chunks instead. - const envelopeBytes = dispatcher.notificationFrameBytes(FS_CHANGED, { events: [] }) - let chunk: RelayWatcherEventPayload[] = [] - let chunkBytes = envelopeBytes - const flush = (): void => { - if (chunk.length === 0) { + + // Rejection is ambiguous: an over-capacity frame is chunkable, a full producer queue is real data loss. + // The empty envelope is encoded once; event JSON sizes are exact deltas apart from array commas. + const eventsCapacity = dispatcher.producerEnvelopeBudget('fs.changed', { events: [] }, clientId) + if (eventsCapacity < 0) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return + } + const { eventBytes, batchBytes } = batchSizing() + if (batchBytes <= eventsCapacity) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return + } + + const grouped = groupedByDirectory() + const groupedEventBytes = grouped.map((event) => eventBytes.get(event)!) + let index = 0 + while (index < grouped.length) { + // Why: the retention ledger covers every producer publication despite its legacy name, and admission + // is lane-agnostic: chunks queued past its low-water reserve (half the 2 MB queue) starve interactive + // PTY frames until pty-handler pauses every remote pane. A resync costs the user far less. + // Per client, never dispatcher-wide: one stalled peer must not cost a healthy client a resync, + // which forces a readDir per directory in its file tree. + if (!dispatcher.producerRetentionBelowLowWater(clientId)) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) return } - dispatcher.notify(FS_CHANGED, { events: chunk }) - chunk = [] - chunkBytes = envelopeBytes - } - for (const event of events) { - const payload: RelayWatcherEventPayload = { - kind: event.type, - absolutePath: event.path, - ...(event.isDirectory === undefined ? {} : { isDirectory: event.isDirectory }) + let end = index + let chunkBytes = 0 + while (end < grouped.length) { + const nextBytes = groupedEventBytes[end] + (end === index ? 0 : 1) + if (chunkBytes + nextBytes > eventsCapacity) { + break + } + chunkBytes += nextBytes + end += 1 } - const payloadBytes = Buffer.byteLength(JSON.stringify(payload)) - if (chunk.length > 0 && chunkBytes + 1 + payloadBytes > frameCapacity) { - flush() + if (end === index || !publish(grouped.slice(index, end))) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) + return } - chunkBytes += payloadBytes + (chunk.length === 0 ? 0 : 1) - chunk.push(payload) + index = end } - flush() +} + +function overflowMarkerState(dispatcher: RelayDispatcher): OverflowMarkerState { + const existing = overflowMarkerStates.get(dispatcher) + if (existing) { + return existing + } + const state: OverflowMarkerState = { + inFlight: new Set(), + pending: new Map(), + capacityUnsubscribes: new Map() + } + overflowMarkerStates.set(dispatcher, state) + // In-flight keys need no sweep here: closing a client settles every queued and written frame first. + dispatcher.onClientDetached((clientId) => { + // Not every detach retires the id: invalidateClient() detaches the primary without removing it and + // setWrite() revives it, so dropping the markers here would desync the tree the reconnect restores. + if (dispatcher.isClientAttached(clientId)) { + return + } + forgetClientMarkers(state, clientId) + }) + return state +} + +function forgetClientMarkers(state: OverflowMarkerState, clientId: number): void { + // Unsubscribe first so no re-entrant flush can observe a half-cleared client. + state.capacityUnsubscribes.get(clientId)?.() + state.capacityUnsubscribes.delete(clientId) + state.pending.delete(clientId) +} + +function overflowMarkerParams(rootPath: string): Record { + return { events: [{ kind: 'overflow', absolutePath: rootPath }] } +} + +// Why: the control lane — on the producer lane the marker would hit the same full queue that just +// rejected the batch and be dropped, silently desyncing the remote file tree. +function emitWatcherOverflowToClient( + dispatcher: RelayDispatcher, + clientId: number, + rootPath: string +): void { + const state = overflowMarkerState(dispatcher) + // Per root, never per client alone: an outstanding marker for one tree must not suppress another's resync. + if ( + state.inFlight.has(`${clientId} ${rootPath}`) || + state.pending.get(clientId)?.has(rootPath) === true + ) { + return + } + publishOverflowMarker(dispatcher, state, clientId, rootPath) +} + +function publishOverflowMarker( + dispatcher: RelayDispatcher, + state: OverflowMarkerState, + clientId: number, + rootPath: string, + estimatedBytes?: number +): void { + const key = `${clientId} ${rootPath}` + const params = overflowMarkerParams(rootPath) + const frameBytes = estimatedBytes ?? dispatcher.notificationFrameBytes('fs.changed', params) + state.inFlight.add(key) + let settled = false + const accepted = dispatcher.tryNotifyClient( + clientId, + 'fs.changed', + params, + (result) => { + // Settles on write, drop, or client close, so the slot can never leak. + settled = true + state.inFlight.delete(key) + if (result.ok) { + return + } + // A frame the sink never wrote leaves the tree just as desynced as a rejected one — setWrite + // fails every queued and in-flight frame this way. Retain unconditionally: a real detach clears + // it through onClientDetached, which fires after this settlement. + retainOverflowMarker(dispatcher, state, clientId, rootPath, frameBytes) + }, + { controlOverflow: 'reject', estimatedBytes: frameBytes } + ) + if (accepted || settled) { + return + } + // Admission rejection has no settlement callback: retain the marker instead of desyncing the tree. + state.inFlight.delete(key) + retainOverflowMarker(dispatcher, state, clientId, rootPath, frameBytes) +} + +function retainOverflowMarker( + dispatcher: RelayDispatcher, + state: OverflowMarkerState, + clientId: number, + rootPath: string, + estimatedBytes: number +): void { + if (!state.capacityUnsubscribes.has(clientId)) { + const unsubscribe = dispatcher.onClientCapacity(clientId, () => + flushPendingOverflowMarkers(dispatcher, state, clientId) + ) + if (!unsubscribe) { + // The client went away between admission and arming, so there is nothing left to resync. + return + } + state.capacityUnsubscribes.set(clientId, unsubscribe) + } + const roots = state.pending.get(clientId) + if (roots) { + roots.set(rootPath, estimatedBytes) + return + } + state.pending.set(clientId, new Map([[rootPath, estimatedBytes]])) +} + +function flushPendingOverflowMarkers( + dispatcher: RelayDispatcher, + state: OverflowMarkerState, + clientId: number +): void { + const roots = state.pending.get(clientId) + if (!roots) { + return + } + for (const [rootPath, estimatedBytes] of Array.from(roots)) { + // Capacity fires on every lane; retry only frames the control queue can admit now. + if (!dispatcher.canAdmitControlFrame(clientId, estimatedBytes)) { + continue + } + // Drop before republishing so a synchronous settlement cannot see the marker as still pending — + // and skip roots a re-entrant flush already took, which would otherwise send the marker twice. + if (!roots.delete(rootPath)) { + continue + } + publishOverflowMarker(dispatcher, state, clientId, rootPath, estimatedBytes) + } + // Identity check: a re-entrant flush may have retired this set and armed a fresh one to keep. + if (roots.size > 0 || state.pending.get(clientId) !== roots) { + return + } + forgetClientMarkers(state, clientId) } export function emitRelayWatcherOverflow( @@ -54,9 +312,10 @@ export function emitRelayWatcherOverflow( rootPath: string, closed: boolean ): void { - if (!closed) { - dispatcher.notify(FS_CHANGED, { - events: [{ kind: 'overflow', absolutePath: rootPath }] - }) + if (closed) { + return + } + for (const clientId of dispatcher.activeClientIds()) { + emitWatcherOverflowToClient(dispatcher, clientId, rootPath) } } diff --git a/src/relay/relay-watcher-frame-chunking.test.ts b/src/relay/relay-watcher-frame-chunking.test.ts index 5d505fb8aaa..8b9782972c8 100644 --- a/src/relay/relay-watcher-frame-chunking.test.ts +++ b/src/relay/relay-watcher-frame-chunking.test.ts @@ -134,7 +134,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = watcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -152,7 +152,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = watcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -170,7 +170,7 @@ describe('relay watcher fs.changed frame chunking', () => { const events = multiByteWatcherBatch(5000) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) for (const frame of capture.frames) { @@ -190,7 +190,7 @@ describe('relay watcher fs.changed frame chunking', () => { const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) expect(capture.frames).toHaveLength(2) @@ -208,7 +208,7 @@ describe('relay watcher fs.changed frame chunking', () => { const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) try { - emitRelayWatcherEvents(dispatcher, false, overshoot) + emitRelayWatcherEvents(dispatcher, '/workspace', false, overshoot) expect(detached).toEqual([]) expect(capture.frames).toHaveLength(2) @@ -221,7 +221,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('sizes chunks against the smallest capacity across attached clients', () => { + it('sizes chunks independently for clients with different capacities', () => { const { capture: attachedCapture, write: attachedWrite } = captureFrames() const { dispatcher, capture, detached } = createDispatcher(NODE_22_HIGH_WATER_MARK) const events = watcherBatch(2000) @@ -232,10 +232,13 @@ describe('relay watcher fs.changed frame chunking', () => { writableLength: () => 0 }) - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) expect(detached).toEqual([]) - for (const frame of [...capture.frames, ...attachedCapture.frames]) { + for (const frame of capture.frames) { + expect(frame.length).toBeLessThanOrEqual(NODE_22_PRODUCER_CAPACITY) + } + for (const frame of attachedCapture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } expect(capture.fsChangedEvents()).toEqual(expectedPayloads(events)) @@ -245,7 +248,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('still closes the client when a single event cannot fit any frame', () => { + it('resyncs without closing when a single event cannot fit any frame', () => { vi.spyOn(process.stderr, 'write').mockReturnValue(true) const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) const oversized: WatcherProcessEvent[] = [ @@ -253,16 +256,16 @@ describe('relay watcher fs.changed frame chunking', () => { ] try { - emitRelayWatcherEvents(dispatcher, false, oversized) + emitRelayWatcherEvents(dispatcher, '/workspace', false, oversized) - expect(detached).toEqual([1]) - expect(capture.frames).toEqual([]) + expect(detached).toEqual([]) + expect(capture.fsChangedEvents()).toEqual([{ kind: 'overflow', absolutePath: '/workspace' }]) } finally { dispatcher.dispose() } }) - it('delivers the prefix then closes when an oversized event appears mid-batch', () => { + it('delivers the prefix then resyncs when an oversized event appears mid-batch', () => { vi.spyOn(process.stderr, 'write').mockReturnValue(true) const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK) const oversized = { @@ -273,13 +276,15 @@ describe('relay watcher fs.changed frame chunking', () => { const events = [...watcherBatch(300), oversized, ...watcherBatch(50)] try { - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) - expect(detached).toEqual([1]) + expect(detached).toEqual([]) const delivered = capture.fsChangedEvents() expect(delivered.length).toBeGreaterThan(0) - expect(delivered).toEqual(expectedPayloads(events).slice(0, delivered.length)) - expect(delivered.some((event) => event.absolutePath === oversized.path)).toBe(false) + expect(delivered.at(-1)).toEqual({ kind: 'overflow', absolutePath: '/workspace' }) + const prefix = delivered.slice(0, -1) + expect(prefix).toEqual(expectedPayloads(events.filter((event) => event !== oversized))) + expect(prefix.some((event) => event.absolutePath === oversized.path)).toBe(false) for (const frame of capture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } @@ -288,7 +293,7 @@ describe('relay watcher fs.changed frame chunking', () => { } }) - it('keeps the fail-closed kill when retained chunks exhaust the producer queue budget', () => { + it('keeps the client alive when retained chunks exhaust the producer queue budget', () => { vi.spyOn(process.stderr, 'write').mockReturnValue(true) // An unsettled sink models retained relay stdout writes. const { dispatcher, capture, detached } = createDispatcher(NODE_21_HIGH_WATER_MARK, { @@ -300,15 +305,18 @@ describe('relay watcher fs.changed frame chunking', () => { for (let round = 0; round < 10 && detached.length === 0; round++) { const events = watcherBatch(5000) emitted.push(...events) - emitRelayWatcherEvents(dispatcher, false, events) + emitRelayWatcherEvents(dispatcher, '/workspace', false, events) } - expect(detached).toEqual([1]) + expect(detached).toEqual([]) const delivered = capture.fsChangedEvents() expect(delivered.length).toBeGreaterThan(0) expect(delivered.length).toBeLessThan(emitted.length) - // The kill truncates delivery at a chunk boundary; nothing is reordered or duplicated. - expect(delivered).toEqual(expectedPayloads(emitted).slice(0, delivered.length)) + const producerEvents = delivered.filter((event) => event.kind !== 'overflow') + expect(delivered.filter((event) => event.kind === 'overflow')).toEqual([ + { kind: 'overflow', absolutePath: '/workspace' } + ]) + expect(producerEvents).toEqual(expectedPayloads(emitted).slice(0, producerEvents.length)) for (const frame of capture.frames) { expect(frame.length).toBeLessThanOrEqual(NODE_21_PRODUCER_CAPACITY) } @@ -326,7 +334,7 @@ describe('relay watcher fs.changed frame chunking', () => { const notify = vi.spyOn(dispatcher, 'notify') try { dispatcher.invalidateClient() - emitRelayWatcherEvents(dispatcher, false, watcherBatch(10)) + emitRelayWatcherEvents(dispatcher, '/workspace', false, watcherBatch(10)) expect(notify).not.toHaveBeenCalled() expect(capture.frames).toEqual([]) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index f807fc6ac2f..4bfc8bf3e92 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -36,9 +36,9 @@ import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-se import { PluginOverlayManager } from './plugin-overlay' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD @@ -51,7 +51,11 @@ import { } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' -import { decideRelayGrace, type RelayGraceBranch } from './relay-grace-branch' +import { + applyRelayGraceTimeConfiguration, + decideRelayGrace, + type RelayGraceBranch +} from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -681,25 +685,14 @@ async function main(): Promise { }) function configureRelayGraceTime(params: Record): { graceTimeMs: number } { - const seconds = Number(params.graceTimeSeconds) - if (Number.isFinite(seconds) && seconds >= 0) { - const previousGraceMs = ptyHandler.configuredGraceTimeMs - // Why: the host sends 0 before system sleep so live remote PTYs survive longer than the ordinary grace window. - ptyHandler.setGraceTimeMs(Math.floor(seconds) * 1000) - // Why: startGrace samples the configured value at arm time, so a raise that lands while the idle - // timer is already running would still fire at the old deadline. Re-arm only on an actual change - // — the host re-asserts the same value on every establish, and restarting the window on those - // would keep a grace alive indefinitely. - if ( - ptyHandler.configuredGraceTimeMs !== previousGraceMs && - graceDeadlineAt !== null && - graceReason !== null && - !shutdownInFlight - ) { - startGrace('grace reconfigured') - } - } - return { graceTimeMs: ptyHandler.configuredGraceTimeMs } + return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { + readConfiguredGraceMs: () => ptyHandler.configuredGraceTimeMs, + writeConfiguredGraceMs: (graceMs) => ptyHandler.setGraceTimeMs(graceMs), + isGraceTimerArmed: () => graceDeadlineAt !== null && graceReason !== null, + isShutdownInFlight: () => shutdownInFlight, + readGraceBranch: () => graceBranch, + startGrace + }) } dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { @@ -714,13 +707,8 @@ async function main(): Promise { const hookServer = new RelayAgentHookServer({ // Why: scope endpoint.env/cmd by socket path so multiple relay daemons on one account can't overwrite each other's hook tokens. endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), - forward: (envelope) => { - // Why: notify is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) - } + // Why: publication is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) }) // Why: await the bind before announcing readiness so the first PTY spawn already sees ORCA_AGENT_HOOK_* env; bind failure is soft (log and continue). try { diff --git a/src/relay/wsl-agent-hook-relay.ts b/src/relay/wsl-agent-hook-relay.ts index 384fbc2c303..7f3ee8e4062 100644 --- a/src/relay/wsl-agent-hook-relay.ts +++ b/src/relay/wsl-agent-hook-relay.ts @@ -21,9 +21,9 @@ import { createInstallPluginsHandler } from './wsl-install-plugins-handler' import { PreflightHandler } from './preflight-handler' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_NOTIFICATION_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' import { sanitizeWslHookInstanceKey, WSL_HOOK_RELAY_INSTANCE_ENV, @@ -68,11 +68,7 @@ async function main(): Promise { endpointDir: wslHookRelayEndpointDir(homedir(), instanceKey), token, preferredPort: windowsPort, - forward: (envelope) => - dispatcher.notify( - AGENT_HOOK_NOTIFICATION_METHOD, - envelope as unknown as Record - ) + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) }) new PreflightHandler(dispatcher) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eca968e1b05..179108b194b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1317,9 +1317,7 @@ function App(): React.JSX.Element { const sessionSnapshots = shouldCaptureSession ? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState) : [] - // Why: one blocking checkpoint closes the immediate-quit race for both - // the narrow view preference and the larger session recovery snapshots. - window.api.app.persistBeforeUnloadSync({ + window.api.app.stageBeforeUnloadSync({ sessions: sessionSnapshots, ui: buildActiveViewUnloadPatch(freshState) }) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 4b41790f87f..bfe17737970 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -443,7 +443,7 @@ describe('renderer startup runtime routing', () => { expect(checkpointBlock).toContain( 'buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)' ) - expect(checkpointBlock).toContain('window.api.app.persistBeforeUnloadSync({') + expect(checkpointBlock).toContain('window.api.app.stageBeforeUnloadSync({') expect(checkpointBlock).toContain('sessions: sessionSnapshots') expect(checkpointBlock).toContain('ui: buildActiveViewUnloadPatch(freshState)') expect(source).toContain( diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index dfc2fe90afe..5e2ef2a6398 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */ import React, { - Suspense, useCallback, useEffect, useLayoutEffect, @@ -9,16 +8,12 @@ import React, { useState, useSyncExternalStore } from 'react' -import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' import { - ArrowDown, ArrowRight, - ArrowUp, Ban, - Braces, Check, CheckCircle2, ChevronDown, @@ -46,7 +41,6 @@ import { Search, Send, Settings, - UndoDot, UserMinus, UserPlus, Wrench, @@ -57,7 +51,7 @@ import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { Accordion, AccordionContent, @@ -75,7 +69,6 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' import type { DecoratedDiffComment } from '@/components/diff-comments/useDiffCommentDecorator' @@ -90,12 +83,6 @@ import { } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - MAX_RENDERED_DIFF_COMBINED_CHARACTERS, - MAX_RENDERED_DIFF_LINES_PER_SIDE, - getLargeDiffRenderLimit, - type LargeDiffRenderLimit -} from '@/components/editor/large-diff-render-limit' import { getCombinedDiffBranchEntriesInTreeOrder, type CombinedDiffFileTreeEntry @@ -142,13 +129,6 @@ import { PR_COMMENT_RESOLVED_CONTAINER_CLASS, type PRCommentGroup } from '@/lib/pr-comment-groups' -import { - createCommentCodeContextExpansionState, - resolveCommentCodeContextExpansionState, - updateCommentCodeContextExpansionState, - type CommentCodeContextLineUpdate -} from '@/components/comment-code-context-state' -import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' import { getCommentReplyTargetCandidates, resolveCommentReplyTarget @@ -163,10 +143,7 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { - emitGitHubWorkItemDetailsCacheMutation, - onGitHubWorkItemDetailsCacheMutation -} from '@/lib/github-work-item-details-cache-events' +import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, @@ -184,13 +161,11 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods } from '../../../shared/github-pr-merge-methods' -import { githubProjectHost } from '../../../shared/github-project-identity' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { findGithubIssueWorkspaceAttachment, @@ -209,7 +184,6 @@ import type { GitHubIssueTimelineItem, GitHubIssueTimelineTarget, GitHubAssignableUser, - GitHubReaction, GitHubPRMergeMethod, GitBranchChangeEntry, GitDiffResult, @@ -238,23 +212,59 @@ import { getCheckCounts, getChecksSummaryLabel } from '@/components/pr-check-counts' +import { + normalizeItemDialogTab, + parseOwnerRepoFromItemUrl, + resolvePullRequestRepo, + type GitHubWorkItemProjectOrigin, + type ItemDialogTab +} from '@/components/github/github-work-item-identity' +import { + addIssueCommentForRepo, + addPRReviewCommentForRepo, + addPRReviewCommentReplyForRepo, + notifyWorkItemDetailsMutation, + setPRFileViewedForRepo +} from '@/components/github/github-work-item-comment-mutations' +import { + runIssueUpdate, + runPullRequestStateUpdate, + runWorkItemBodyUpdate +} from '@/components/github/github-work-item-edit-mutations' +import { + PR_FILE_CONTENT_CACHE_MAX_BYTES, + getRetainedPRFileContentsByteCount, + isPRFileViewed +} from '@/components/github/pr-file-content-size' +import { + PR_DIFF_OVERSCAN, + getPRFileContentsRenderLimit, + getPRFileDiffResult, + getPRFileSectionKey, + gitHubPRFileToBranchEntry, + type PRFilesCombinedDiffViewerProps +} from '@/components/github/pr-file-diff-mapping' +import { + buildRequestedReviewUsers, + formatRelativeTime, + getStateLabel, + mergeReviewerSuggestions, + ReviewerAvatar +} from '@/components/github/work-item-state-presentation' +import { + formatCheckTimestamp, + getCheckDetailsKey, + getCheckStatusLabel +} from '@/components/github/pr-check-presentation' +import { CommentCodeContext } from '@/components/github/CommentCodeContext' +import { CommentReactions } from '@/components/github/CommentReactions' +import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' +import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' -// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. -function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { - try { - const parsed = new URL(url) - if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { - return null - } - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 2) { - return null - } - return { owner: segments[0], repo: segments[1], host: parsed.host } - } catch { - return null - } -} +export type { ItemDialogTab } + +/** Re-exported so Project-view callers keep a stable import path. */ +export type GitHubItemDialogProjectOrigin = GitHubWorkItemProjectOrigin function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { try { @@ -276,67 +286,6 @@ function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { } } -const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) - -export type ItemDialogTab = 'conversation' | 'checks' | 'files' - -const CODE_CONTEXT_EXPAND_STEP = 5 -const CODE_CONTEXT_FALLBACK_LINES = 20 -const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 - -const REACTION_EMOJI: Record = { - '+1': '👍', - '-1': '👎', - laugh: '😄', - confused: '😕', - heart: '❤️', - hooray: '🎉', - rocket: '🚀', - eyes: '👀' -} - -function normalizeItemDialogTab( - item: GitHubWorkItem | null, - tab: ItemDialogTab | undefined -): ItemDialogTab { - if (item?.type !== 'pr') { - return 'conversation' - } - return tab ?? 'conversation' -} - -/** Why: a Project row may not belong to the active repo; when set, mutations route through slug-addressed IPCs against `owner`/`repo` so edits don't land on the workspace's repo. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. */ -export type GitHubItemDialogProjectOrigin = { - owner: string - repo: string - /** GitHub host (e.g. GHES); absent means github.com. */ - host?: string - number: number - type: 'issue' | 'pr' - projectId: string - projectItemId: string - cacheKey: string -} - -// Why: every PR mutation needs the same host-pinned identity so process GH_HOST -// cannot redirect a github.com item or a Project row to the wrong server. -function resolvePullRequestRepo( - item: Pick, - projectOrigin?: Pick -): GitHubOwnerRepo | null { - const repo = - item.prRepo ?? - (projectOrigin - ? { - owner: projectOrigin.owner, - repo: projectOrigin.repo, - host: projectOrigin.host - } - : null) ?? - parseOwnerRepoFromItemUrl(item.url) - return repo ? { ...repo, host: githubProjectHost(repo.host) } : null -} - type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null @@ -355,41 +304,6 @@ type GitHubItemDialogProps = { projectOrigin?: GitHubItemDialogProjectOrigin } -function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') -} - -function getStateLabel(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'Merged' - } - if (item.state === 'draft') { - return 'Draft' - } - if (item.state === 'closed') { - return 'Closed' - } - return 'Open' - } - return item.state === 'closed' ? 'Closed' : 'Open' -} - function getStateTone(item: GitHubWorkItem): string { if (item.type === 'pr') { if (item.state === 'merged') { @@ -430,288 +344,6 @@ function WorkItemStateBadge({ ) } -function ReviewerAvatar({ - login, - avatarUrl -}: { - login: string - avatarUrl: string -}): React.JSX.Element { - return -} - -function mergeReviewerSuggestions( - users: GitHubAssignableUser[], - seedUsers: GitHubAssignableUser[] -): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of [...seedUsers, ...users]) { - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (!existing) { - byLogin.set(key, user) - continue - } - if (!existing.avatarUrl && user.avatarUrl) { - byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) - } - } - return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) -} - -function buildRequestedReviewUsers( - logins: string[], - candidates: GitHubAssignableUser[], - existingRequests: GitHubAssignableUser[] -): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of existingRequests) { - byLogin.set(user.login.toLowerCase(), user) - } - const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) - for (const login of logins) { - const key = login.toLowerCase() - if (byLogin.has(key)) { - continue - } - byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) -} - -function PRAssigneesPanel({ - item, - repoPath, - projectOrigin, - sourceContext, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - sourceContext?: TaskSourceContext | null - onMutated: () => void -}): React.JSX.Element { - const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) - const [localAssignees, setLocalAssignees] = useState( - () => item.assignees ?? [] - ) - const [assigneesSource, setAssigneesSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - assignees: item.assignees - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const { isPending, run } = useImmediateMutation() - - // Why: a background refetch can change PR assignees; sync before paint so the right rail never shows a stale reviewer/assignee split. - if ( - assigneesSource.itemId !== item.id || - assigneesSource.repoId !== item.repoId || - assigneesSource.assignees !== item.assignees - ) { - setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) - setLocalAssignees(item.assignees ?? []) - } - - const patchProjectRowIfNeeded = useCallback( - (assignees: string[]) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) - }, - [patchProjectRowContent, projectOrigin] - ) - const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) - const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null - const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null - const repoAssigneesBySlug = useRepoAssigneesBySlug( - slugOwner, - slugRepo, - assigneeLogins, - sourceSettings, - projectOrigin?.host ?? assigneeSlug?.host - ) - const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) - const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath - const canEditAssignees = Boolean(projectOrigin || repoPath) - const assigneesByLogin = useMemo( - () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), - [repoAssignees.data] - ) - - const handleAssigneeToggle = useCallback( - (login: string) => { - const lowerLogin = login.toLowerCase() - const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) - const prevAssignees = localAssignees - const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } - const nextAssignees = isAssigned - ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) - : [...prevAssignees, candidate] - const nextLogins = nextAssignees.map((user) => user.login) - const prevLogins = prevAssignees.map((user) => user.login) - - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(nextAssignees) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(nextLogins) - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(prevLogins) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - }, - [ - assigneesByLogin, - item.id, - item.number, - item.repoId, - localAssignees, - onMutated, - patchProjectRowIfNeeded, - patchWorkItem, - projectOrigin, - repoPath, - run, - sourceContext - ] - ) - - const checkIcon = ( - - - - ) - - return ( -
-
- {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} - - - - - - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => { - const selected = localAssignees.some( - (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() - ) - return ( - - ) - })} -
- )} -
-
-
- {localAssignees.length === 0 ? ( -
- {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} -
- ) : ( -
    - {localAssignees.map((assignee) => ( -
  • - - - {assignee.login} - -
  • - ))} -
- )} - - ) -} - function PRReviewersPanel({ item, loading, @@ -1415,10 +1047,6 @@ function PRReviewersPanel({ ) } -function isPRFileViewed(file: GitHubPRFile): boolean { - return file.viewerViewedState === 'VIEWED' -} - // Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. const WORK_ITEM_DETAILS_CACHE_MAX = 50 const WORK_ITEM_DETAILS_FRESH_MS = 30_000 @@ -1608,9 +1236,6 @@ if (typeof import.meta !== 'undefined' && import.meta.hot) { // Why: bounded LRU so opening many PRs with many files doesn't grow this module-level map unboundedly until reload. const PR_FILE_CONTENT_CACHE_MAX = 64 -// Why: overflow is a sentinel; force reported size past the render budget so downstream checks reliably pick fallback mode. -const GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT = MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1 -const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 type PRFileContentCacheEntry = { value: Promise | GitHubPRFileContents byteCount: number @@ -1618,48 +1243,6 @@ type PRFileContentCacheEntry = { const prFileContentCache = new Map() let prFileContentCacheBytes = 0 -function getUtf8ByteCount(value: string): number { - let byteCount = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x80) { - byteCount += 1 - } else if (code < 0x800) { - byteCount += 2 - } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - byteCount += 4 - index += 1 - } else { - byteCount += 3 - } - } else { - byteCount += 3 - } - } - return byteCount -} - -function isPRFileContentsTooLargeSentinel(contents: GitHubPRFileContents): boolean { - return contents.originalTooLarge === true || contents.modifiedTooLarge === true -} - -function getPRFileContentsCacheByteCount(contents: GitHubPRFileContents): number { - if (isPRFileContentsTooLargeSentinel(contents)) { - return 0 - } - return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) -} - -function getRetainedPRFileContentsByteCount(contents: GitHubPRFileContents): number | null { - if (isPRFileContentsTooLargeSentinel(contents)) { - return 0 - } - const byteCount = getPRFileContentsCacheByteCount(contents) - return byteCount <= PR_FILE_CONTENT_CACHE_MAX_BYTES ? byteCount : null -} - function touchPRFileContentCache( key: string, value: Promise | GitHubPRFileContents @@ -1787,406 +1370,6 @@ function loadPRFileContents(args: { return request } -function addIssueCommentForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addIssueComment', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - number: args.number, - body: args.body, - prRepo: args.prRepo ?? null - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: args.type ?? 'issue', - number: args.number - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addIssueComment({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - number: args.number, - body: args.body, - type: args.type, - prRepo: args.prRepo ?? null - }) -} - -function addPRReviewCommentForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addPRReviewComment', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commitId: args.commitId, - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addPRReviewComment({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commitId: args.commitId, - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body - }) -} - -function addPRReviewCommentReplyForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commentId: number - body: string - threadId?: string - path?: string - line?: number -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addPRReviewCommentReply', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commentId: args.commentId, - body: args.body, - threadId: args.threadId, - path: args.path, - line: args.line - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addPRReviewCommentReply({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commentId: args.commentId, - body: args.body, - threadId: args.threadId, - path: args.path, - line: args.line - }) -} - -function notifyWorkItemDetailsMutation( - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - type: 'issue' | 'pr' - number: number - }, - options: { local?: boolean } = {} -): void { - if (options.local !== false) { - emitGitHubWorkItemDetailsCacheMutation(args) - } - void window.api.gh - .notifyWorkItemMutated({ - repoPath: args.repoPath, - repoId: args.repoId, - type: args.type, - number: args.number - }) - .catch(() => undefined) -} - -function setPRFileViewedForRepo(args: { - repoId: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean -}): Promise { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.setPRFileViewed', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId, - path: args.path, - viewed: args.viewed - }, - { timeoutMs: 30_000 } - ).then((ok) => { - if (ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return ok - }) - } - return window.api.gh.setPRFileViewed({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId, - path: args.path, - viewed: args.viewed - }) -} - -function PRViewedCheckbox({ - checked, - pending, - filePath, - onToggle -}: { - checked: boolean - pending: boolean - filePath: string - onToggle: () => void -}): React.JSX.Element { - return ( - - - - - - {checked - ? translate('auto.components.GitHubItemDialog.ba8e329d92', 'Unmark viewed') - : translate('auto.components.GitHubItemDialog.16c1abe76c', 'Mark viewed')} - - - ) -} - -const PR_DIFF_OVERSCAN = 5 - -function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['status'] { - switch (status) { - case 'added': - return 'added' - case 'removed': - return 'deleted' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - case 'modified': - case 'unchanged': - return 'modified' - } -} - -function getPRFileSectionKey(path: string): string { - return `combined-commit:${path}` -} - -function gitHubPRFileToBranchEntry(file: GitHubPRFile): GitBranchChangeEntry { - return { - path: file.path, - oldPath: file.oldPath, - status: mapPRFileStatus(file.status), - added: file.additions, - removed: file.deletions - } -} - -function getPRFileContentsRenderLimit(contents: GitHubPRFileContents): LargeDiffRenderLimit { - if (!contents.originalTooLarge && !contents.modifiedTooLarge) { - return getLargeDiffRenderLimit({ - originalContent: contents.original, - modifiedContent: contents.modified - }) - } - - return { - limited: true, - reason: 'character-count' as const, - lineCounts: null, - characterCount: - contents.original.length + - contents.modified.length + - (contents.originalTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0) + - (contents.modifiedTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0), - limits: { - maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE, - maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS - } - } -} - -function getPRFileDiffResult(contents: GitHubPRFileContents): GitDiffResult { - if (contents.originalIsBinary) { - return { - kind: 'binary', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: true, - modifiedIsBinary: contents.modifiedIsBinary - } - } - if (contents.modifiedIsBinary) { - return { - kind: 'binary', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: false, - modifiedIsBinary: true - } - } - - return { - kind: 'text', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: false, - modifiedIsBinary: false - } -} - -type PRFilesCombinedDiffViewerProps = { - files: GitHubPRFile[] - comments: PRComment[] - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - prUrl: string - headSha: string | undefined - baseSha: string | undefined - pendingViewedPaths: ReadonlySet - onCommentAdded: (comment: PRComment) => void - onViewedChange: (path: string, viewed: boolean) => Promise -} - function PRFilesCombinedDiffViewer({ files, comments, @@ -2732,317 +1915,6 @@ function PRFilesCombinedDiffViewer({ ) } -function CommentCodeContext({ - comment, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - files, - headSha, - baseSha -}: { - comment: PRComment - repoPath: string | null - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined -}): React.JSX.Element | null { - const [contents, setContents] = useState(null) - const [error, setError] = useState(false) - const [contextExpansionState, setContextExpansionState] = useState(() => - createCommentCodeContextExpansionState(comment.id) - ) - const file = useMemo( - () => files.find((candidate) => candidate.path === comment.path), - [comment.path, files] - ) - const line = comment.line - const startLine = comment.startLine ?? line - - useEffect(() => { - setContents(null) - setError(false) - if (!repoPath || !file || !headSha || !baseSha || !line || file.isBinary) { - return - } - let cancelled = false - loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - .then((result) => { - if (!cancelled) { - setContents(result) - } - }) - .catch(() => { - if (!cancelled) { - setError(true) - } - }) - return () => { - cancelled = true - } - }, [baseSha, file, headSha, line, prNumber, prRepo, repoId, repoPath, sourceContext]) - - const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( - contextExpansionState, - comment.id - ) - if (resolvedContextExpansionState !== contextExpansionState) { - // Why: comment rows can be reused on PR refresh; reset before paint so the previous comment's expanded context isn't shown on the next. - setContextExpansionState(resolvedContextExpansionState) - } - const contextBefore = resolvedContextExpansionState.contextBefore - const contextAfter = resolvedContextExpansionState.contextAfter - const setContextBefore = useCallback( - (contextBeforeUpdate: CommentCodeContextLineUpdate) => { - setContextExpansionState((current) => - updateCommentCodeContextExpansionState(current, comment.id, { - contextBefore: contextBeforeUpdate - }) - ) - }, - [comment.id] - ) - const setContextAfter = useCallback( - (contextAfterUpdate: CommentCodeContextLineUpdate) => { - setContextExpansionState((current) => - updateCommentCodeContextExpansionState(current, comment.id, { - contextAfter: contextAfterUpdate - }) - ) - }, - [comment.id] - ) - - if (!comment.path || !line || !file || file.isBinary || error) { - return null - } - - if (!contents) { - return ( -
- - {translate('auto.components.GitHubItemDialog.db61d76cd5', 'Loading code context…')} -
- ) - } - - if (getPRFileContentsRenderLimit(contents).limited) { - return null - } - - const source = contents.modified || contents.original - const codeContext = getPrCommentCodeContext({ - source, - line, - startLine, - contextBefore, - contextAfter, - fallbackLines: CODE_CONTEXT_FALLBACK_LINES, - maxBlockLines: CODE_CONTEXT_MAX_BLOCK_LINES - }) - if (!codeContext) { - return null - } - const { - selectedLines, - totalLines, - commentFrom, - commentTo, - from, - to, - blockRange, - shouldUseBlockRange, - canExpandAbove, - canExpandBelow, - canExpandBlock - } = codeContext - const language = detectLanguage(comment.path) - const blockTooltip = shouldUseBlockRange - ? 'Show surrounding code block' - : 'Show nearby code context' - - return ( -
-
-
- {comment.path} - - L{from} - {to !== from - ? translate('auto.components.GitHubItemDialog.d1c0dad471', '-L{{value0}}', { - value0: to - }) - : ''} - - {(from !== commentFrom || to !== commentTo) && ( - - {translate('auto.components.GitHubItemDialog.bd7be7b1fd', 'comment L')} - {commentFrom} - {commentTo !== commentFrom - ? translate('auto.components.GitHubItemDialog.d1c0dad471', '-L{{value0}}', { - value0: commentTo - }) - : ''} - - )} -
- - {(contextBefore > 0 || contextAfter > 0) && ( - - - - - - {translate('auto.components.GitHubItemDialog.b1574e8ac2', 'Reset code context')} - - - )} - - - - - - {translate('auto.components.GitHubItemDialog.5664681624', 'Show more lines above')} - - - - - - - - {translate('auto.components.GitHubItemDialog.06c06e58ba', 'Show more lines below')} - - - - - - - {blockTooltip} - - -
- - {selectedLines.map((codeLine, index) => { - const lineNumber = from + index - const isCommentedLine = lineNumber >= commentFrom && lineNumber <= commentTo - return ( -
- - {lineNumber} - - {codeLine || ' '} -
- ) - })} - - } - > - -
-
- ) -} - type IssueConversationEntry = | { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number } | { @@ -3446,6 +2318,7 @@ function ConversationTab({ files={files} headSha={headSha} baseSha={baseSha} + loadPRFileContents={loadPRFileContents} /> reaction.count > 0) - if (visibleReactions.length === 0) { - return null - } - - return ( -
- {visibleReactions.map((reaction) => ( - - - {reaction.count} - - ))} -
- ) -} - function CommentReplyForm({ className, placeholder, @@ -4327,58 +3166,6 @@ function CommentReplyForm({ ) } -function getCheckStatusLabel(check: PRCheckDetail): string { - const conclusion = getCheckConclusion(check) - if (conclusion === 'success') { - return 'Successful' - } - if (conclusion === 'failure') { - return 'Failed' - } - if (conclusion === 'cancelled') { - return 'Cancelled' - } - if (conclusion === 'timed_out') { - return 'Timed out' - } - if (conclusion === 'action_required') { - return 'Action required' - } - if (conclusion === 'neutral') { - return 'Neutral' - } - if (conclusion === 'skipped') { - return 'Skipped' - } - if (check.status === 'queued') { - return 'Queued' - } - if (check.status === 'in_progress') { - return 'In progress' - } - return 'Pending' -} - -function getCheckDetailsKey(check: PRCheckDetail): string { - return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) -} - -function formatCheckTimestamp(input: string | null | undefined): string | null { - if (!input) { - return null - } - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return null - } - return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) -} - function ChecksTab({ item, repoPath, @@ -5173,267 +3960,6 @@ function ChecksTab({ ) } -// Why: for a Project row whose repo differs from the active workspace, mutations must target the row's actual repo via slug-addressed IPCs, else edits silently apply to the workspace's repo. -// Why: these edit IPCs return `{ ok, error }`; callers throw on `!ok` so useImmediateMutation (which expects throws on failure) works unchanged. -function getGitHubMutationSettings(repoId: string | null | undefined) { - const state = useAppStore.getState() - // Why: even slug-addressed project-origin mutations must run on the backing repo's owner host when its id is known. - return getSettingsForRepoRuntimeOwner(state, repoId ?? null) -} - -async function runIssueUpdate(args: { - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - number: number - updates: Parameters[0]['updates'] -}): Promise { - if (args.projectOrigin) { - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), - number: args.number, - updates: args.updates - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updateIssueBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updateIssueBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } - return - } - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (!args.repoPath && !runtimeHost) { - throw new Error('No repo context available for this edit.') - } - const res = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.updateIssue', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), - number: args.number, - updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssue({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - number: args.number, - updates: args.updates - }) - if (!res.ok) { - throw new Error(res.error) - } - if (runtimeHost) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } -} - -async function runWorkItemBodyUpdate(args: { - item: GitHubWorkItem - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - body: string - parsedSlug: GitHubOwnerRepo | null -}): Promise { - if (args.item.type === 'pr') { - const targetSlug = args.projectOrigin - ? { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: args.projectOrigin.host - } - : args.parsedSlug - if (!targetSlug) { - throw new Error('No GitHub repository context available for this pull request.') - } - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.item.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: targetSlug.owner, - repo: targetSlug.repo, - host: githubProjectHost(targetSlug.host), - number: args.item.number, - updates: { body: args.body } - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updatePullRequestBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updatePullRequestBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.item.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.item.number - }, - { local: false } - ) - } - return - } - - await runIssueUpdate({ - repoPath: args.repoPath, - repoId: args.item.repoId, - sourceContext: args.sourceContext, - projectOrigin: args.projectOrigin, - number: args.item.number, - updates: { body: args.body } - }) -} - -async function runPullRequestStateUpdate(args: { - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: GitHubItemDialogProjectOrigin | undefined - number: number - prRepo?: GitHubOwnerRepo | null - updates: { state: 'open' | 'closed' } -}): Promise { - if (args.projectOrigin) { - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), - number: args.number, - updates: args.updates - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updatePullRequestBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updatePullRequestBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'pr', - number: args.number - }, - { local: false } - ) - } - return - } - // Why: close/reopen must route by the repo owner host like merge (#6957). - const target = getActiveRuntimeTarget( - getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) - ) - if (!args.repoPath && target.kind !== 'environment') { - throw new Error('No repo context available for this pull request.') - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.updatePRState', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), - prNumber: args.number, - prRepo: args.prRepo ?? null, - updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePRState({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - prNumber: args.number, - prRepo: args.prRepo ?? null, - updates: args.updates - }) - if (!res.ok) { - throw new Error(res.error) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'pr', - number: args.number - }, - { local: false } - ) - } -} - function GitHubLabelsSettingsLink({ url, separated, diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index c00abfad692..43198a9be9d 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: duplicated from GitHubItemDialog so the dedicated PR full-page surface can evolve its Primer-styled header without destabilizing the issue dialog; planned to refactor shared parts out later. */ import React, { - Suspense, useCallback, useEffect, useLayoutEffect, @@ -9,16 +8,12 @@ import React, { useState, useSyncExternalStore } from 'react' -import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' import { useShallow } from 'zustand/react/shallow' import type { editor as monacoEditor } from 'monaco-editor' import { - ArrowDown, ArrowLeft, ArrowRight, - ArrowUp, - Braces, Check, ChevronDown, ChevronLeft, @@ -41,7 +36,6 @@ import { Plus, RefreshCw, Send, - UndoDot, Wrench, X } from 'lucide-react' @@ -50,7 +44,7 @@ import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { Input } from '@/components/ui/input' import { useMountedRef } from '@/hooks/useMountedRef' -import { useConfirmationDialog } from '@/components/confirmation-dialog' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { Accordion, AccordionContent, @@ -68,7 +62,6 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { detectLanguage } from '@/lib/language-detect' import { cn } from '@/lib/utils' import { setWithLRU } from '@/lib/scroll-cache' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -85,12 +78,6 @@ import { } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { - MAX_RENDERED_DIFF_COMBINED_CHARACTERS, - MAX_RENDERED_DIFF_LINES_PER_SIDE, - getLargeDiffRenderLimit, - type LargeDiffRenderLimit -} from '@/components/editor/large-diff-render-limit' import { getCombinedDiffBranchEntriesInTreeOrder, type CombinedDiffFileTreeEntry @@ -138,13 +125,6 @@ import { PR_COMMENT_RESOLVED_CONTAINER_CLASS, type PRCommentGroup } from '@/lib/pr-comment-groups' -import { - createCommentCodeContextExpansionState, - resolveCommentCodeContextExpansionState, - updateCommentCodeContextExpansionState, - type CommentCodeContextLineUpdate -} from '@/components/comment-code-context-state' -import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' @@ -166,10 +146,7 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { - emitGitHubWorkItemDetailsCacheMutation, - onGitHubWorkItemDetailsCacheMutation -} from '@/lib/github-work-item-details-cache-events' +import { onGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, @@ -182,7 +159,6 @@ import { GITHUB_PR_MERGE_METHOD_LABELS, resolveGitHubPRMergeMethods } from '../../../shared/github-pr-merge-methods' -import { githubProjectHost } from '../../../shared/github-project-identity' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { findGithubPrWorkspaceAttachment, @@ -213,7 +189,6 @@ import type { GitHubWorkItem, GitHubWorkItemDetails, GitHubAssignableUser, - GitHubReaction, GitHubPRMergeMethod, GitBranchChangeEntry, GitDiffResult, @@ -234,27 +209,58 @@ import { getCheckCounts, getChecksSummaryLabel } from '@/components/pr-check-counts' +import { + normalizeItemDialogTab, + parseOwnerRepoFromItemUrl, + resolvePullRequestRepo, + type GitHubWorkItemProjectOrigin, + type ItemDialogTab +} from '@/components/github/github-work-item-identity' +import { + addIssueCommentForRepo, + addPRReviewCommentForRepo, + addPRReviewCommentReplyForRepo, + notifyWorkItemDetailsMutation, + setPRFileViewedForRepo +} from '@/components/github/github-work-item-comment-mutations' +import { + runIssueUpdate, + runPullRequestStateUpdate, + runWorkItemBodyUpdate +} from '@/components/github/github-work-item-edit-mutations' +import { + PR_FILE_CONTENT_CACHE_MAX_BYTES, + getRetainedPRFileContentsByteCount, + isPRFileViewed +} from '@/components/github/pr-file-content-size' +import { + PR_DIFF_OVERSCAN, + getPRFileContentsRenderLimit, + getPRFileDiffResult, + getPRFileSectionKey, + gitHubPRFileToBranchEntry, + type PRFilesCombinedDiffViewerProps +} from '@/components/github/pr-file-diff-mapping' +import { + buildRequestedReviewUsers, + formatRelativeTime, + getStateLabel, + mergeReviewerSuggestions, + ReviewerAvatar +} from '@/components/github/work-item-state-presentation' +import { + formatCheckTimestamp, + getCheckDetailsKey, + getCheckStatusLabel +} from '@/components/github/pr-check-presentation' +import { CommentCodeContext } from '@/components/github/CommentCodeContext' +import { CommentReactions } from '@/components/github/CommentReactions' +import { PRAssigneesPanel } from '@/components/github/PRAssigneesPanel' +import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' -// Why: the item URL is the only host-aware repository identity present on every work item across IPC. -function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { - try { - const parsed = new URL(url) - if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { - return null - } - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments.length < 2) { - return null - } - return { owner: segments[0], repo: segments[1], host: parsed.host } - } catch { - return null - } -} +export type { ItemDialogTab } -const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) - -export type ItemDialogTab = 'conversation' | 'checks' | 'files' +export type PullRequestPageProjectOrigin = GitHubWorkItemProjectOrigin type MentionOption = { login: string @@ -268,62 +274,6 @@ type MentionQuery = { query: string } -const CODE_CONTEXT_EXPAND_STEP = 5 -const CODE_CONTEXT_FALLBACK_LINES = 20 -const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 - -const REACTION_EMOJI: Record = { - '+1': '👍', - '-1': '👎', - laugh: '😄', - confused: '😕', - heart: '❤️', - hooray: '🎉', - rocket: '🚀', - eyes: '👀' -} - -function normalizeItemDialogTab( - item: GitHubWorkItem | null, - tab: ItemDialogTab | undefined -): ItemDialogTab { - if (item?.type !== 'pr') { - return 'conversation' - } - return tab ?? 'conversation' -} - -/** When set, GHEditSection routes label/assignee/state edits through slug-addressed IPCs (owner/repo) instead of `repoPath`, so Project rows from another repo aren't edited on the workspace repo. See docs/design/github-project-view-tasks.md. */ -export type PullRequestPageProjectOrigin = { - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - projectId: string - projectItemId: string - cacheKey: string -} - -// Why: every PR mutation needs the same host-pinned identity so process GH_HOST -// cannot redirect a github.com item or a Project row to the wrong server. -function resolvePullRequestRepo( - item: Pick, - projectOrigin?: Pick -): GitHubOwnerRepo | null { - const repo = - item.prRepo ?? - (projectOrigin - ? { - owner: projectOrigin.owner, - repo: projectOrigin.repo, - host: projectOrigin.host - } - : null) ?? - parseOwnerRepoFromItemUrl(item.url) - return repo ? { ...repo, host: githubProjectHost(repo.host) } : null -} - type PullRequestPageProps = { workItem: GitHubWorkItem | null repoPath: string | null @@ -342,25 +292,6 @@ type PullRequestPageProps = { projectOrigin?: PullRequestPageProjectOrigin } -function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') -} - function findMentionQuery(value: string, caret: number): MentionQuery | null { const beforeCaret = value.slice(0, caret) const match = /(^|[\s([{,])@([A-Za-z0-9-]*)$/.exec(beforeCaret) @@ -423,22 +354,6 @@ function buildMentionOptions({ return Array.from(byLogin.values()) } -function getStateLabel(item: GitHubWorkItem): string { - if (item.type === 'pr') { - if (item.state === 'merged') { - return 'Merged' - } - if (item.state === 'draft') { - return 'Draft' - } - if (item.state === 'closed') { - return 'Closed' - } - return 'Open' - } - return item.state === 'closed' ? 'Closed' : 'Open' -} - function getStateTone(item: GitHubWorkItem): string { if (item.type === 'pr') { if (item.state === 'merged') { @@ -478,285 +393,6 @@ function WorkItemStateBadge({ ) } -function ReviewerAvatar({ - login, - avatarUrl -}: { - login: string - avatarUrl: string -}): React.JSX.Element { - return -} - -function mergeReviewerSuggestions( - users: GitHubAssignableUser[], - seedUsers: GitHubAssignableUser[] -): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of [...seedUsers, ...users]) { - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (!existing) { - byLogin.set(key, user) - continue - } - if (!existing.avatarUrl && user.avatarUrl) { - byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) - } - } - return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) -} - -function buildRequestedReviewUsers( - logins: string[], - candidates: GitHubAssignableUser[], - existingRequests: GitHubAssignableUser[] -): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of existingRequests) { - byLogin.set(user.login.toLowerCase(), user) - } - const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) - for (const login of logins) { - const key = login.toLowerCase() - if (byLogin.has(key)) { - continue - } - byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) - } - return Array.from(byLogin.values()) -} - -function PRAssigneesPanel({ - item, - repoPath, - projectOrigin, - sourceContext, - onMutated -}: { - item: GitHubWorkItem - repoPath: string | null - projectOrigin: PullRequestPageProjectOrigin | undefined - sourceContext?: TaskSourceContext | null - onMutated: () => void -}): React.JSX.Element { - const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) - const [localAssignees, setLocalAssignees] = useState( - () => item.assignees ?? [] - ) - const [assigneesSource, setAssigneesSource] = useState(() => ({ - itemId: item.id, - repoId: item.repoId, - assignees: item.assignees - })) - const patchWorkItem = useAppStore((s) => s.patchWorkItem) - const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) - const repoOwnerSettings = useAppStore( - useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) - ) - const sourceSettings = useMemo( - () => - sourceContext?.provider === 'github' - ? ({ - ...repoOwnerSettings, - ...getTaskSourceRuntimeSettings(sourceContext) - } as typeof repoOwnerSettings) - : repoOwnerSettings, - [repoOwnerSettings, sourceContext] - ) - const { isPending, run } = useImmediateMutation() - - // Why: sync assignees before paint (background refetches change them) so the right rail never shows a stale split. - if ( - assigneesSource.itemId !== item.id || - assigneesSource.repoId !== item.repoId || - assigneesSource.assignees !== item.assignees - ) { - setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) - setLocalAssignees(item.assignees ?? []) - } - - const patchProjectRowIfNeeded = useCallback( - (assignees: string[]) => { - if (!projectOrigin) { - return - } - patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) - }, - [patchProjectRowContent, projectOrigin] - ) - const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) - const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) - const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null - const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null - const repoAssigneesBySlug = useRepoAssigneesBySlug( - slugOwner, - slugRepo, - assigneeLogins, - sourceSettings, - projectOrigin?.host ?? assigneeSlug?.host - ) - const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) - const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath - const canEditAssignees = Boolean(projectOrigin || repoPath) - const assigneesByLogin = useMemo( - () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), - [repoAssignees.data] - ) - - const handleAssigneeToggle = useCallback( - (login: string) => { - const lowerLogin = login.toLowerCase() - const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) - const prevAssignees = localAssignees - const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } - const nextAssignees = isAssigned - ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) - : [...prevAssignees, candidate] - const nextLogins = nextAssignees.map((user) => user.login) - const prevLogins = prevAssignees.map((user) => user.login) - - run('assignees', { - mutate: () => - runIssueUpdate({ - repoId: item.repoId, - repoPath, - sourceContext, - projectOrigin, - number: item.number, - updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } - }), - onOptimistic: () => { - setLocalAssignees(nextAssignees) - patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(nextLogins) - }, - onRevert: () => { - setLocalAssignees(prevAssignees) - patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded(prevLogins) - }, - onSuccess: () => { - useAppStore.getState().recordFeatureInteraction('github-tasks') - onMutated() - }, - onError: (err) => toast.error(err) - }) - }, - [ - assigneesByLogin, - item.id, - item.number, - item.repoId, - localAssignees, - onMutated, - patchProjectRowIfNeeded, - patchWorkItem, - projectOrigin, - repoPath, - run, - sourceContext - ] - ) - - const checkIcon = ( - - - - ) - - return ( -
-
- {translate('auto.components.PullRequestPage.8ff5ae8866', 'Assignees')} - - - - - - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => { - const selected = localAssignees.some( - (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() - ) - return ( - - ) - })} -
- )} -
-
-
- {localAssignees.length === 0 ? ( -
- {translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')} -
- ) : ( -
    - {localAssignees.map((assignee) => ( -
  • - - - {assignee.login} - -
  • - ))} -
- )} -
- ) -} - function PRReviewersPanel({ item, loading, @@ -1458,10 +1094,6 @@ function PRReviewersPanel({ ) } -function isPRFileViewed(file: GitHubPRFile): boolean { - return file.viewerViewedState === 'VIEWED' -} - // SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md. const WORK_ITEM_DETAILS_CACHE_MAX = 50 const WORK_ITEM_DETAILS_FRESH_MS = 30_000 @@ -1651,9 +1283,6 @@ if (typeof import.meta !== 'undefined' && import.meta.hot) { // Why: bounded LRU so a session of opening many PR files can't grow this module map without bound. const PR_FILE_CONTENT_CACHE_MAX = 64 -// Why: overflow sentinel — force reported size past the render budget so downstream reliably picks fallback mode. -const GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT = MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1 -const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 type PRFileContentCacheEntry = { value: Promise | GitHubPRFileContents byteCount: number @@ -1661,48 +1290,6 @@ type PRFileContentCacheEntry = { const prFileContentCache = new Map() let prFileContentCacheBytes = 0 -function getUtf8ByteCount(value: string): number { - let byteCount = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x80) { - byteCount += 1 - } else if (code < 0x800) { - byteCount += 2 - } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - byteCount += 4 - index += 1 - } else { - byteCount += 3 - } - } else { - byteCount += 3 - } - } - return byteCount -} - -function isPRFileContentsTooLargeSentinel(contents: GitHubPRFileContents): boolean { - return contents.originalTooLarge === true || contents.modifiedTooLarge === true -} - -function getPRFileContentsCacheByteCount(contents: GitHubPRFileContents): number { - if (isPRFileContentsTooLargeSentinel(contents)) { - return 0 - } - return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) -} - -function getRetainedPRFileContentsByteCount(contents: GitHubPRFileContents): number | null { - if (isPRFileContentsTooLargeSentinel(contents)) { - return 0 - } - const byteCount = getPRFileContentsCacheByteCount(contents) - return byteCount <= PR_FILE_CONTENT_CACHE_MAX_BYTES ? byteCount : null -} - function touchPRFileContentCache( key: string, value: Promise | GitHubPRFileContents @@ -1830,306 +1417,6 @@ function loadPRFileContents(args: { return request } -function addIssueCommentForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addIssueComment', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - number: args.number, - body: args.body, - prRepo: args.prRepo ?? null - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: args.type ?? 'issue', - number: args.number - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addIssueComment({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - number: args.number, - body: args.body, - type: args.type, - prRepo: args.prRepo ?? null - }) -} - -function addPRReviewCommentForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addPRReviewComment', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commitId: args.commitId, - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addPRReviewComment({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commitId: args.commitId, - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body - }) -} - -function addPRReviewCommentReplyForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commentId: number - body: string - threadId?: string - path?: string - line?: number -}): Promise>> { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.addPRReviewCommentReply', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commentId: args.commentId, - body: args.body, - threadId: args.threadId, - path: args.path, - line: args.line - }, - { timeoutMs: 30_000 } - ).then((result) => { - if (result.ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return result - }) - } - return window.api.gh.addPRReviewCommentReply({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - commentId: args.commentId, - body: args.body, - threadId: args.threadId, - path: args.path, - line: args.line - }) -} - -function notifyWorkItemDetailsMutation( - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - type: 'issue' | 'pr' - number: number - }, - options: { local?: boolean } = {} -): void { - if (options.local !== false) { - emitGitHubWorkItemDetailsCacheMutation(args) - } - void window.api.gh - .notifyWorkItemMutated({ - repoPath: args.repoPath, - repoId: args.repoId, - type: args.type, - number: args.number - }) - .catch(() => undefined) -} - -function setPRFileViewedForRepo(args: { - repoId: string - repoPath: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean -}): Promise { - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (runtimeHost) { - return callRuntimeRpc( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.setPRFileViewed', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId, - path: args.path, - viewed: args.viewed - }, - { timeoutMs: 30_000 } - ).then((ok) => { - if (ok) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.prNumber - }, - { local: false } - ) - } - return ok - }) - } - return window.api.gh.setPRFileViewed({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - prNumber: args.prNumber, - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId, - path: args.path, - viewed: args.viewed - }) -} - -function PRViewedCheckbox({ - checked, - pending, - filePath, - onToggle -}: { - checked: boolean - pending: boolean - filePath: string - onToggle: () => void -}): React.JSX.Element { - return ( - - - - - - {checked - ? translate('auto.components.PullRequestPage.2b4fdb880c', 'Unmark viewed') - : translate('auto.components.PullRequestPage.50b8fb290f', 'Mark viewed')} - - - ) -} - -const PR_DIFF_OVERSCAN = 5 - type CachedPRFilesDiffViewState = { entrySignature: string sections: DiffSection[] @@ -2144,106 +1431,6 @@ type CachedPRFilesDiffViewState = { const prFilesDiffViewStateCache = new Map() const prFilesDiffScrollTopCache = new Map() -function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['status'] { - switch (status) { - case 'added': - return 'added' - case 'removed': - return 'deleted' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - case 'modified': - case 'unchanged': - return 'modified' - } -} - -function getPRFileSectionKey(path: string): string { - return `combined-commit:${path}` -} - -function gitHubPRFileToBranchEntry(file: GitHubPRFile): GitBranchChangeEntry { - return { - path: file.path, - oldPath: file.oldPath, - status: mapPRFileStatus(file.status), - added: file.additions, - removed: file.deletions - } -} - -function getPRFileContentsRenderLimit(contents: GitHubPRFileContents): LargeDiffRenderLimit { - if (!contents.originalTooLarge && !contents.modifiedTooLarge) { - return getLargeDiffRenderLimit({ - originalContent: contents.original, - modifiedContent: contents.modified - }) - } - - return { - limited: true, - reason: 'character-count' as const, - lineCounts: null, - characterCount: - contents.original.length + - contents.modified.length + - (contents.originalTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0) + - (contents.modifiedTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0), - limits: { - maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE, - maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS - } - } -} - -function getPRFileDiffResult(contents: GitHubPRFileContents): GitDiffResult { - if (contents.originalIsBinary) { - return { - kind: 'binary', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: true, - modifiedIsBinary: contents.modifiedIsBinary - } - } - if (contents.modifiedIsBinary) { - return { - kind: 'binary', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: false, - modifiedIsBinary: true - } - } - - return { - kind: 'text', - originalContent: contents.original, - modifiedContent: contents.modified, - originalIsBinary: false, - modifiedIsBinary: false - } -} - -type PRFilesCombinedDiffViewerProps = { - files: GitHubPRFile[] - comments: PRComment[] - repoPath: string - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - prUrl: string - headSha: string | undefined - baseSha: string | undefined - pendingViewedPaths: ReadonlySet - onCommentAdded: (comment: PRComment) => void - onViewedChange: (path: string, viewed: boolean) => Promise -} - function PRFilesCombinedDiffViewer({ files, comments, @@ -2900,317 +2087,6 @@ function PRFilesCombinedDiffViewer({ ) } -function CommentCodeContext({ - comment, - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - files, - headSha, - baseSha -}: { - comment: PRComment - repoPath: string | null - repoId: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - files: GitHubPRFile[] - headSha: string | undefined - baseSha: string | undefined -}): React.JSX.Element | null { - const [contents, setContents] = useState(null) - const [error, setError] = useState(false) - const [contextExpansionState, setContextExpansionState] = useState(() => - createCommentCodeContextExpansionState(comment.id) - ) - const file = useMemo( - () => files.find((candidate) => candidate.path === comment.path), - [comment.path, files] - ) - const line = comment.line - const startLine = comment.startLine ?? line - - useEffect(() => { - setContents(null) - setError(false) - if (!repoPath || !file || !headSha || !baseSha || !line || file.isBinary) { - return - } - let cancelled = false - loadPRFileContents({ - repoPath, - repoId, - sourceContext, - prNumber, - prRepo, - file, - headSha, - baseSha - }) - .then((result) => { - if (!cancelled) { - setContents(result) - } - }) - .catch(() => { - if (!cancelled) { - setError(true) - } - }) - return () => { - cancelled = true - } - }, [baseSha, file, headSha, line, prNumber, prRepo, repoId, repoPath, sourceContext]) - - const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( - contextExpansionState, - comment.id - ) - if (resolvedContextExpansionState !== contextExpansionState) { - // Why: rows are reused across PR refreshes; reset before paint so the previous comment's expanded context isn't shown on the next. - setContextExpansionState(resolvedContextExpansionState) - } - const contextBefore = resolvedContextExpansionState.contextBefore - const contextAfter = resolvedContextExpansionState.contextAfter - const setContextBefore = useCallback( - (contextBeforeUpdate: CommentCodeContextLineUpdate) => { - setContextExpansionState((current) => - updateCommentCodeContextExpansionState(current, comment.id, { - contextBefore: contextBeforeUpdate - }) - ) - }, - [comment.id] - ) - const setContextAfter = useCallback( - (contextAfterUpdate: CommentCodeContextLineUpdate) => { - setContextExpansionState((current) => - updateCommentCodeContextExpansionState(current, comment.id, { - contextAfter: contextAfterUpdate - }) - ) - }, - [comment.id] - ) - - if (!comment.path || !line || !file || file.isBinary || error) { - return null - } - - if (!contents) { - return ( -
- - {translate('auto.components.PullRequestPage.4b960e5978', 'Loading code context…')} -
- ) - } - - if (getPRFileContentsRenderLimit(contents).limited) { - return null - } - - const source = contents.modified || contents.original - const codeContext = getPrCommentCodeContext({ - source, - line, - startLine, - contextBefore, - contextAfter, - fallbackLines: CODE_CONTEXT_FALLBACK_LINES, - maxBlockLines: CODE_CONTEXT_MAX_BLOCK_LINES - }) - if (!codeContext) { - return null - } - const { - selectedLines, - totalLines, - commentFrom, - commentTo, - from, - to, - blockRange, - shouldUseBlockRange, - canExpandAbove, - canExpandBelow, - canExpandBlock - } = codeContext - const language = detectLanguage(comment.path) - const blockTooltip = shouldUseBlockRange - ? 'Show surrounding code block' - : 'Show nearby code context' - - return ( -
-
-
- {comment.path} - - L{from} - {to !== from - ? translate('auto.components.PullRequestPage.84fc40769a', '-L{{value0}}', { - value0: to - }) - : ''} - - {(from !== commentFrom || to !== commentTo) && ( - - {translate('auto.components.PullRequestPage.791ddede19', 'comment L')} - {commentFrom} - {commentTo !== commentFrom - ? translate('auto.components.PullRequestPage.84fc40769a', '-L{{value0}}', { - value0: commentTo - }) - : ''} - - )} -
- - {(contextBefore > 0 || contextAfter > 0) && ( - - - - - - {translate('auto.components.PullRequestPage.5f3e293517', 'Reset code context')} - - - )} - - - - - - {translate('auto.components.PullRequestPage.c9de94b07a', 'Show more lines above')} - - - - - - - - {translate('auto.components.PullRequestPage.51ed0cf38b', 'Show more lines below')} - - - - - - - {blockTooltip} - - -
- - {selectedLines.map((codeLine, index) => { - const lineNumber = from + index - const isCommentedLine = lineNumber >= commentFrom && lineNumber <= commentTo - return ( -
- - {lineNumber} - - {codeLine || ' '} -
- ) - })} - - } - > - -
-
- ) -} - function ConversationTab({ item, repoPath, @@ -3590,6 +2466,7 @@ function ConversationTab({ files={files} headSha={headSha} baseSha={baseSha} + loadPRFileContents={loadPRFileContents} /> reaction.count > 0) - if (visibleReactions.length === 0) { - return null - } - - return ( -
- {visibleReactions.map((reaction) => ( - - - {reaction.count} - - ))} -
- ) -} - function CommentReplyForm({ className, placeholder, @@ -4336,58 +3179,6 @@ function CommentReplyForm({ ) } -function getCheckStatusLabel(check: PRCheckDetail): string { - const conclusion = getCheckConclusion(check) - if (conclusion === 'success') { - return 'Successful' - } - if (conclusion === 'failure') { - return 'Failed' - } - if (conclusion === 'cancelled') { - return 'Cancelled' - } - if (conclusion === 'timed_out') { - return 'Timed out' - } - if (conclusion === 'action_required') { - return 'Action required' - } - if (conclusion === 'neutral') { - return 'Neutral' - } - if (conclusion === 'skipped') { - return 'Skipped' - } - if (check.status === 'queued') { - return 'Queued' - } - if (check.status === 'in_progress') { - return 'In progress' - } - return 'Pending' -} - -function getCheckDetailsKey(check: PRCheckDetail): string { - return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) -} - -function formatCheckTimestamp(input: string | null | undefined): string | null { - if (!input) { - return null - } - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return null - } - return date.toLocaleString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }) -} - function ChecksTab({ item, repoPath, @@ -5456,266 +4247,6 @@ function MentionTextarea({ ) } -// Why: Project-row mutations must target the row's repo via slug-addressed IPCs, else edits silently apply to the active workspace's repo. -function getGitHubMutationSettings(repoId: string | null | undefined) { - const state = useAppStore.getState() - // Why: slug-addressed project-origin mutations must still run on the backing repo's owner host when we know its id. - return getSettingsForRepoRuntimeOwner(state, repoId ?? null) -} - -async function runIssueUpdate(args: { - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: PullRequestPageProjectOrigin | undefined - number: number - updates: Parameters[0]['updates'] -}): Promise { - if (args.projectOrigin) { - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), - number: args.number, - updates: args.updates - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updateIssueBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updateIssueBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } - return - } - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (!args.repoPath && !runtimeHost) { - throw new Error('No repo context available for this edit.') - } - const res = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.updateIssue', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), - number: args.number, - updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssue({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - number: args.number, - updates: args.updates - }) - if (!res.ok) { - throw new Error(res.error) - } - if (runtimeHost) { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'issue', - number: args.number - }, - { local: false } - ) - } -} - -async function runWorkItemBodyUpdate(args: { - item: GitHubWorkItem - repoPath: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: PullRequestPageProjectOrigin | undefined - body: string - parsedSlug: GitHubOwnerRepo | null -}): Promise { - if (args.item.type === 'pr') { - const targetSlug = args.projectOrigin - ? { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: args.projectOrigin.host - } - : args.parsedSlug - if (!targetSlug) { - throw new Error('No GitHub repository context available for this pull request.') - } - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.item.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: targetSlug.owner, - repo: targetSlug.repo, - host: githubProjectHost(targetSlug.host), - number: args.item.number, - updates: { body: args.body } - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updatePullRequestBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updatePullRequestBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.item.repoId, - sourceContext: args.sourceContext, - type: 'pr', - number: args.item.number - }, - { local: false } - ) - } - return - } - - await runIssueUpdate({ - repoPath: args.repoPath, - repoId: args.item.repoId, - sourceContext: args.sourceContext, - projectOrigin: args.projectOrigin, - number: args.item.number, - updates: { body: args.body } - }) -} - -async function runPullRequestStateUpdate(args: { - repoPath: string | null - repoId?: string | null - sourceContext?: TaskSourceContext | null - projectOrigin: PullRequestPageProjectOrigin | undefined - number: number - prRepo?: GitHubOwnerRepo | null - updates: { state: 'open' | 'closed' } -}): Promise { - if (args.projectOrigin) { - const targetSettings = - args.sourceContext?.provider === 'github' - ? getTaskSourceRuntimeSettings(args.sourceContext) - : getGitHubMutationSettings(args.repoId) - const target = getActiveRuntimeTarget(targetSettings) - const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - host: githubProjectHost(args.projectOrigin.host), - number: args.number, - updates: args.updates - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updatePullRequestBySlug', - updateArgs, - { - timeoutMs: 30_000 - } - ) - : await window.api.gh.updatePullRequestBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'pr', - number: args.number - }, - { local: false } - ) - } - return - } - // Why: close/reopen must route by the repo owner host like merge (#6957). - const target = getActiveRuntimeTarget( - getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) - ) - if (!args.repoPath && target.kind !== 'environment') { - throw new Error('No repo context available for this pull request.') - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.updatePRState', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), - prNumber: args.number, - prRepo: args.prRepo ?? null, - updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePRState({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - prNumber: args.number, - prRepo: args.prRepo ?? null, - updates: args.updates - }) - if (!res.ok) { - throw new Error(res.error) - } - if (target.kind === 'environment') { - notifyWorkItemDetailsMutation( - { - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - type: 'pr', - number: args.number - }, - { local: false } - ) - } -} - function GHEditSection({ item, repoPath, diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5c30fa545d0..99d0785c795 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -103,7 +103,7 @@ import type { TaskSourceAvailabilityNotice, TaskSourceHostAvailability } from './task-source-context-summary' -import { useConfirmationDialog } from '@/components/confirmation-dialog' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { getGitHubPRPrimaryReviewer, getGitHubPRReviewerRows, @@ -205,7 +205,12 @@ import { } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' import { + applyEmptyPageClamp, + applyWindowPageLimit, + buildSelectedReposKey, + deriveAdvertisedTotalPages, getTaskPagePerRepoLimit, + resolveEmptyPageOutcome, taskPageToGitHubApiPage } from '@/components/task-page-work-item-pagination' import { sortWorkItemsByNumber } from '../../../shared/work-items' @@ -3175,6 +3180,18 @@ export default function TaskPage(): React.JSX.Element { [eligibleRepos, repoSelection] ) + // Why: see buildSelectedReposKey — array-identity deps re-fire on every + // repos:changed even when the selection is unchanged. The context part is + // resolved as GitHub, but every provider-independent field (projectId, + // hostId, projectHostSetupId, repoId) is identical across providers, so the + // GitLab effect can key off this too — it passes no gitlabProjectRef, so its + // context carries no providerIdentity of its own. Thread a projectRef into + // that call and this key needs a GitLab-scoped part. + const selectedReposKey = useMemo( + () => buildSelectedReposKey(selectedRepos, (r) => getTaskPageRepoSourceContext(r, 'github')), + [selectedRepos] + ) + // Why: many affordances need *a* repo; use the first selected as default, while cross-repo dialogs still let the user override per-action. const primaryRepo = selectedRepos[0] ?? null const linearWorkspaces = linearStatus.workspaces ?? [] @@ -3777,14 +3794,34 @@ export default function TaskPage(): React.JSX.Element { const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) const [countedTotalPages, setCountedTotalPages] = useState(null) + // Proven window-422 page limit — separate from the count so a late count + // can't resurrect proven-unreachable pages, nor be pinned by a speculative + // withdrawal (see deriveAdvertisedTotalPages). + const [provenPageLimit, setProvenPageLimit] = useState(null) + // Why: synchronous mirror of countedTotalPages — the empty-page branch needs + // the committed value, not a click-time closure, and refs update immediately. + const countedTotalPagesRef = useRef(null) const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage) const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos) + // Why: keyed on selectedReposKey, not the selectedRepos array — a background + // repos:changed refresh mid-flight would otherwise bump the generation and + // silently discard the user's page navigation (#11485). Mirrors every dep of + // the fetch effect that resets page state, so a reset always invalidates + // in-flight page requests. useEffect(() => { paginationGenerationRef.current += 1 setPaginationLoading(false) setLoadingTargetPage(null) - }, [selectedRepos, appliedTaskSearch, workItemsInvalidationNonce]) + }, [ + selectedReposKey, + appliedTaskSearch, + workItemsInvalidationNonce, + taskRefreshNonce, + taskSource, + githubMode, + taskResumeApplied + ]) // Why: the dialog's "Use" button routes through the same direct-launch flow as the row-level "Use" CTA so behavior is consistent regardless of entry point. const githubTaskDrawerWorkItem = useAppStore((s) => s.githubTaskDrawerWorkItem) @@ -4799,15 +4836,6 @@ export default function TaskPage(): React.JSX.Element { jiraTaskSourceContext ]) - // Why: stable string key for selectedRepos so the GitLab effect doesn't re-run on every parent render from a new array ref. - const selectedReposKey = useMemo( - () => - selectedRepos - .map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}|${r.executionHostId ?? ''}`) - .join(','), - [selectedRepos] - ) - // Why: fetch GitLab Issues and MRs separately so errors stay isolated per tab (mirrors GitHub's split endpoints). useEffect(() => { if (taskSource !== 'gitlab') { @@ -4914,7 +4942,7 @@ export default function TaskPage(): React.JSX.Element { return () => { stale = true } - // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey encodes the only selectedRepos fields read above; keying off the array ref would re-run on every parent render. + // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey covers every selectedRepos field read above (see its GitHub-scoped-context note); keying off the array ref would re-run on every parent render. }, [taskSource, gitlabView, activeGitlabFilter, gitlabRefreshNonce, selectedReposKey]) // Why: Todos fetch has its own effect — different trigger (no chip filter) and data path (gl.todos is user-scoped, not repo-scoped). @@ -6111,10 +6139,12 @@ export default function TaskPage(): React.JSX.Element { const fallbackTotalPages = lastLoadedPageFull ? Math.max(pages.length, lastLoadedPageIndex + 2) : Math.max(1, pages.length) - const totalPages = - countedTotalPages && countedTotalPages > 0 - ? Math.max(pages.length, countedTotalPages) - : fallbackTotalPages + const totalPages = deriveAdvertisedTotalPages({ + loadedPages: pages.length, + countedTotalPages, + fallbackTotalPages, + provenPageLimit + }) // Why: load only the clicked page so a high-page jump doesn't exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( @@ -6135,7 +6165,7 @@ export default function TaskPage(): React.JSX.Element { setPaginationLoading(true) setLoadingTargetPage(target) try { - const { items } = await fetchWorkItemsNextPage( + const { items, failedCount, errorTypes } = await fetchWorkItemsNextPage( repoArgs, githubPerRepoPageLimit, githubPageSize, @@ -6146,6 +6176,55 @@ export default function TaskPage(): React.JSX.Element { return } if (items.length === 0) { + // Why: see resolveEmptyPageOutcome — a dead click needs feedback only + // when something actually failed; a clean empty probe is end-of-data. + // The reason never depends on the count, so it's safe to derive here; + // the clamp is not (see applyEmptyPageClamp) and runs in the updater. + const { reason } = resolveEmptyPageOutcome({ + target, + failedCount, + errorTypes, + countedTotalPages: null + }) + if (reason === 'window-unreachable') { + toast.error( + translate( + 'auto.components.TaskPage.loadPageUnreachable', + 'Page {{value0}} is beyond what GitHub search can return.', + { value0: String(target + 1) } + ), + { id: 'work-items-page-unreachable' } + ) + setProvenPageLimit((previous) => applyWindowPageLimit(previous, target)) + } else if (reason === 'load-failed') { + toast.error( + translate( + 'auto.components.TaskPage.loadPageFailed', + 'Page {{value0}} could not be loaded from GitHub.', + { value0: String(target + 1) } + ), + { id: 'work-items-page-load-failed' } + ) + } else { + // Why: with a real count the clamp is refused, so without feedback + // the click would look dead — the count over-advertised; nothing + // failed, so the copy stays neutral. The ref carries the committed + // count, immune to the click-time closure race. + const committedCount = countedTotalPagesRef.current + if (committedCount !== null && committedCount > 0) { + toast( + translate( + 'auto.components.TaskPage.loadPageNoMoreResults', + 'No more results on page {{value0}}.', + { value0: String(target + 1) } + ), + { id: 'work-items-page-no-more-results' } + ) + } + const next = applyEmptyPageClamp(committedCount, { target, failedCount, errorTypes }) + countedTotalPagesRef.current = next + setCountedTotalPages(next) + } return } setPages((previous) => { @@ -6253,6 +6332,8 @@ export default function TaskPage(): React.JSX.Element { setPages([page0]) setCurrentPage(0) setCountedTotalPages(null) + countedTotalPagesRef.current = null + setProvenPageLimit(null) setTasksError(null) setFailedCount(0) // reset so a prior failure banner doesn't linger setGithubUnavailable(false) @@ -6357,6 +6438,10 @@ export default function TaskPage(): React.JSX.Element { githubPerRepoPageLimit ).then(({ totalPages: countedPages }) => { if (!cancelled) { + // Why: the count overwrites unconditionally — proven window limits live + // in provenPageLimit, so a late count can't be pinned by a speculative + // end-of-data withdrawal, and can't resurrect proven-dead pages either. + countedTotalPagesRef.current = countedPages setCountedTotalPages(countedPages) } }) @@ -6365,9 +6450,13 @@ export default function TaskPage(): React.JSX.Element { cancelled = true } // Why: store selectors are stable (omit from deps); workItemsInvalidationNonce included so a preference flip re-dispatches. + // selectedReposKey stands in for selectedRepos — the array gets a fresh + // identity on every repos:changed event, and re-running this effect then + // resets pagination mid-click (#11485). The key covers every repo field the + // requests read. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - selectedRepos, + selectedReposKey, appliedTaskSearch, taskRefreshNonce, taskSource, diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 561dd4e8c40..505c88e648a 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -45,7 +45,7 @@ import type { } from '../../../shared/types' import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' -import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer' +import { RetainedBrowserPaneOverlayLayer } from './browser-pane/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' import { useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility' import { useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' @@ -64,7 +64,7 @@ import { import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' -import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair' +import { useActiveTerminalRepair } from './terminal/use-active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' import { applyBackgroundMountTabRestriction, @@ -390,7 +390,10 @@ function Terminal(): React.JSX.Element | null { }, [foregroundTerminalTabIds]) const tabs = useMemo( - () => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []), + () => + renderedActiveWorktreeId !== null && Object.hasOwn(tabsByWorktree, renderedActiveWorktreeId) + ? tabsByWorktree[renderedActiveWorktreeId] + : [], [renderedActiveWorktreeId, tabsByWorktree] ) useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded) @@ -749,32 +752,15 @@ function Terminal(): React.JSX.Element | null { ) }, [queueEditorCloseRequests]) - useEffect(() => { - const rememberedTabId = renderedActiveWorktreeId - ? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null) - : null - // Why: prefer the remembered active tab so a repair on a transient switch render doesn't reset selection to Terminal 1. - const repairedTabId = resolveRepairedActiveTerminalTabId({ - activeTabType, - activeTabId, - rememberedTabId, - tabs - }) - if (!repairedTabId) { - return - } - // Why: run in an effect (Zustand mutation during render trips React's cross-component update warning); keep terminal-only so inactive CLI-created tabs can't steal editor/browser focus. - setActiveTab(repairedTabId) - // Why: `tabs` is the dependency so the repair reacts to tab-order/content changes, not just scalar IDs. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ + // Why: repair after render so Zustand mutation cannot trip React's cross-component update warning. + useActiveTerminalRepair({ activeTabId, activeTabType, setActiveTab, tabs, activeTabIdByWorktree, renderedActiveWorktreeId - ]) + }) // Why: only mount TerminalPanes for visited worktrees, else restoring many saved tabs mass-spawns PTYs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) @@ -2688,11 +2674,19 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ backgroundMountTabIds={backgroundMountTabIds} activationDeferredMountTabIds={activationDeferredMountTabIds} /> + {/* Why: once eligible, retain slot DOM so hidden worktrees keep their Electron guests alive (STA-3228). */} + {isVisible || backgroundMountTabIds === null ? ( - <> - - - + ) : null} diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9eee43a6a4b..c85a2d700e7 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1838,7 +1838,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -1966,7 +1966,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2013,7 +2013,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2054,7 +2054,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2135,7 +2135,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
@@ -2213,7 +2213,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground' )} > -
+
diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 50190295daa..10d9fc2afa6 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -15,7 +15,10 @@ import { resolveActivityPortalSwap, type ActivityPortalThreadRef } from './activity-portal-thread-reconciliation' -import type { ActivityPortalReadinessStatus } from './activity-portal-readiness-oscillation' +import { + ACTIVITY_PORTAL_READINESS_MAX_FLIPS, + type ActivityPortalReadinessStatus +} from './activity-portal-readiness-oscillation' const WORKTREE_ID = 'wt-1' const TAB_ID = 'tab-react185' @@ -85,6 +88,28 @@ async function flushPortalFramesUntil( } } +// Drain MutationObserver microtasks and the readiness rAF they schedule. Reports whether the +// drain settled so a caller never reads a transition whose readiness callbacks are still queued. +async function flushPortalReadiness( + frames: ReturnType +): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + await act(async () => { + await Promise.resolve() + }) + if (frames.pending() === 0) { + await act(async () => { + await Promise.resolve() + }) + if (frames.pending() === 0) { + return true + } + } + await frames.flush() + } + return frames.pending() === 0 +} + // Models the tab-root DOM and sibling hiding emitted by a portaled TerminalPane. function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: string[]): void { const isolatedLeafId = leafIds[0] @@ -354,23 +379,47 @@ describe('Activity portal pane switching', () => { await act(async () => { root.render() }) + expect(await flushPortalReadiness(frames)).toBe(true) await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'unavailable') expect(statuses.at(-1)).toBe('unavailable') - // Feed each observed DOM state separately so MutationObserver cannot coalesce the flips. - for (let flip = 0; flip < 9; flip += 1) { + // Feed each DOM state separately and keep going until sibling DOM reports latched + // unavailable. A fixed 9-flip budget flakes when CI load drops MutationObserver + // deliveries below ACTIVITY_PORTAL_READINESS_MAX_FLIPS transitions. + let sawSiblingLoading = false + let sawLatchedSibling = false + for ( + let flip = 0; + flip < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 4 && !sawLatchedSibling; + flip += 1 + ) { + const mode = flip % 2 === 0 ? 'sibling' : 'hidden' + const statusesBefore = statuses.length await act(async () => { - buildRoot(flip % 2 === 0 ? 'sibling' : 'hidden') + buildRoot(mode) await Promise.resolve() }) - await frames.flush() + expect(await flushPortalReadiness(frames)).toBe(true) + if (mode !== 'sibling') { + continue + } + // Transition-local evidence: an unlatched subscription answers sibling DOM with 'loading', + // so the latch is only proven once a sibling transition that previously emitted 'loading' + // stops doing so and leaves 'unavailable' standing. + if (statuses.slice(statusesBefore).includes('loading')) { + sawSiblingLoading = true + } else if (sawSiblingLoading && statuses.at(-1) === 'unavailable') { + sawLatchedSibling = true + } } + expect(sawLatchedSibling).toBe(true) expect(statuses.at(-1)).toBe('unavailable') await act(async () => { buildRoot('ready') await Promise.resolve() }) + expect(await flushPortalReadiness(frames)).toBe(true) await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') expect(statuses.at(-1)).toBe('ready') }) diff --git a/src/renderer/src/components/automations/automation-page-parts.tsx b/src/renderer/src/components/automations/automation-page-parts.tsx index 7d96be1efa3..9b38cac5fc1 100644 --- a/src/renderer/src/components/automations/automation-page-parts.tsx +++ b/src/renderer/src/components/automations/automation-page-parts.tsx @@ -107,12 +107,3 @@ export function Field({
) } - -export function Metric({ label, value }: { label: string; value: string }): React.JSX.Element { - return ( -
-
{label}
-
{value}
-
- ) -} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 60cc98d8d7c..1a2c0279543 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -3693,7 +3693,6 @@ function BrowserPagePane({ container, inputLocked: inputLockedRef.current, webviewPartition, - allowWindowClose: browserTab.allowWindowClose === true, resolveContainer: () => ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null }) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts index 496b23ec409..8d9e541d8fe 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts +++ b/src/renderer/src/components/browser-pane/BrowserPane.webview-preferences.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' -import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' const registryMocks = vi.hoisted(() => ({ destroyPersistentWebview: vi.fn(), @@ -105,21 +104,35 @@ describe('BrowserPane webview preferences', () => { expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element) }) - it('marks explicitly allowed CLI pages before the guest attaches', () => { - const container = createContainer('cli-page') - - const ensuredWebview = ensureBrowserPageWebview({ - browserTabId: 'browser-page-cli', - container, - inputLocked: false, - webviewPartition: 'persist:orca-browser', - allowWindowClose: true, - resolveContainer: () => container + it('keeps a replacement viewport while rebuilding a parent-drifted webview (STA-3228)', () => { + const staleContainer = createContainer('stale') + const staleWebview = document.createElement('webview') as Electron.WebviewTag + staleWebview.setAttribute('partition', 'persist:orca-browser') + staleContainer.appendChild(staleWebview) + registryMocks.webviewRegistry.set('browser-page-1', staleWebview) + const replacementContainer = createContainer('replacement') + const resolveContainer = vi.fn(() => replacementContainer) + registryMocks.destroyPersistentWebview.mockImplementation(() => { + staleWebview.remove() + staleContainer.remove() + registryMocks.webviewRegistry.delete('browser-page-1') }) - expect(ensuredWebview?.webview.getAttribute('preload')).toBe( - BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD + const ensuredWebview = ensureBrowserPageWebview({ + browserTabId: 'browser-page-1', + container: replacementContainer, + inputLocked: false, + webviewPartition: 'persist:orca-browser', + resolveContainer + }) + + expect(registryMocks.destroyPersistentWebview).toHaveBeenCalledWith('browser-page-1', { + preserveViewport: true + }) + expect(ensuredWebview?.container).toBe(replacementContainer) + expect(replacementContainer.isConnected).toBe(true) + expect(replacementContainer.lastElementChild).toBe( + ensuredWebview?.webview as unknown as Element ) - expect(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD).toMatch(/^file:/) }) }) diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx index 4e49c241722..e23df8ae29e 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx @@ -1,5 +1,8 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { Suspense } from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' type MockAppState = { @@ -54,7 +57,7 @@ vi.mock('./BrowserPane', () => ({ ) })) -import BrowserPaneOverlayLayer from './BrowserPaneOverlayLayer' +import BrowserPaneOverlayLayer, { RetainedBrowserPaneOverlayLayer } from './BrowserPaneOverlayLayer' describe('BrowserPaneOverlayLayer', () => { beforeEach(() => { @@ -64,6 +67,61 @@ describe('BrowserPaneOverlayLayer', () => { mocks.state = createState() }) + afterEach(() => cleanup()) + + it('defers browser slots for a restricted hidden mount, then retains them after activation', () => { + const view = render( + + ) + + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0) + + view.rerender( + + ) + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(2) + + view.rerender( + + ) + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(2) + }) + + it('does not retain browser slots from an eligible render that never commits', () => { + const pending = new Promise(() => {}) + const BlockCommit = ({ blocked }: { blocked: boolean }): null => { + if (blocked) { + throw pending + } + return null + } + const renderBoundary = (mountEligible: boolean, blocked: boolean) => ( + }> + + + + ) + const view = render(renderBoundary(false, false)) + + view.rerender(renderBoundary(true, true)) + expect(view.container.querySelector('[data-suspended]')).not.toBeNull() + + view.rerender(renderBoundary(false, false)) + expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0) + }) + it('keeps inactive browser panes mounted for a visible worktree', () => { const markup = renderOverlay({ isWorktreeActive: true }) diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index b019f1025e7..67d6aa4da9e 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react' import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' @@ -194,4 +194,26 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ ) }) +export const RetainedBrowserPaneOverlayLayer = memo(function RetainedBrowserPaneOverlayLayer({ + worktreeId, + isWorktreeActive, + mountEligible +}: { + worktreeId: string + isWorktreeActive: boolean + mountEligible: boolean +}): React.JSX.Element | null { + const [hasCommittedMount, setHasCommittedMount] = useState(false) + // Why: commit the latch with the persistent slot DOM so discarded renders cannot retain a guest host. + useLayoutEffect(() => { + if (mountEligible && !hasCommittedMount) { + setHasCommittedMount(true) + } + }, [hasCommittedMount, mountEligible]) + if (!mountEligible && !hasCommittedMount) { + return null + } + return +}) + export default BrowserPaneOverlayLayer diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index d18c331c801..6b092f2c950 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -72,11 +72,6 @@ export function isBrowserAutomationVisible(browserPageId: string): boolean { return (leaseCountsByPageId.get(browserPageId) ?? 0) > 0 } -export function useBrowserAutomationVisibility(browserPageId: string | null | undefined): boolean { - useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) - return Boolean(browserPageId && isBrowserAutomationVisible(browserPageId)) -} - export function useBrowserAutomationVisibilityForAny( browserPageIds: readonly (string | null | undefined)[] ): boolean { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts index 9172ddae7f2..c4df770d2eb 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts @@ -46,6 +46,41 @@ describe('ensureBrowserPageViewport', () => { it('returns null until the slot viewport root is registered', () => { expect(ensureBrowserPageViewport('page-1', 'workspace-missing')).toBeNull() }) + + it('reuses the cached viewport while the slot root is unchanged', () => { + mountSlotViewport('workspace-1') + const first = ensureBrowserPageViewport('page-1', 'workspace-1') + const second = ensureBrowserPageViewport('page-1', 'workspace-1') + + expect(second).toBe(first) + }) + + it('keeps the parked viewport while the slot root is unregistered', () => { + const root = mountSlotViewport('workspace-1') + const parked = ensureBrowserPageViewport('page-1', 'workspace-1') + root.remove() + registerBrowserOverlaySlotViewport('workspace-1', null) + + expect(ensureBrowserPageViewport('page-1', 'workspace-1')).toBe(parked) + }) + + it('rebuilds under a replacement slot root instead of returning the stranded shell (STA-3228)', () => { + const oldRoot = mountSlotViewport('workspace-1') + const stale = ensureBrowserPageViewport('page-1', 'workspace-1')! + // Worktree overlay unmounts while hidden: slot root leaves the DOM with the shell inside. + oldRoot.remove() + registerBrowserOverlaySlotViewport('workspace-1', null) + const newRoot = mountSlotViewport('workspace-1') + + const rebuilt = ensureBrowserPageViewport('page-1', 'workspace-1') + + expect(rebuilt).not.toBeNull() + expect(rebuilt).not.toBe(stale) + expect(rebuilt!.shell.parentElement).toBe(newRoot) + expect(rebuilt!.shell.isConnected).toBe(true) + expect(stale.shell.isConnected).toBe(false) + expect(getBrowserPageViewportContainer('page-1')).toBe(rebuilt!.container) + }) }) describe('syncBrowserPageChromeInset', () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.ts index f6cae76105a..d34fbf744f4 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.ts @@ -65,11 +65,16 @@ export function ensureBrowserPageViewport( browserPageId: string, workspaceTabId: string ): BrowserPageViewport | null { + const root = slotViewportRoots.get(workspaceTabId) const existing = browserPageViewports.get(browserPageId) if (existing) { - return existing + if (!root || existing.shell.parentElement === root) { + return existing + } + // Why: a remounted slot strands this shell after Electron destroys its detached guest (STA-3228). + existing.shell.remove() + browserPageViewports.delete(browserPageId) } - const root = slotViewportRoots.get(workspaceTabId) if (!root) { return null } diff --git a/src/renderer/src/components/browser-pane/browser-page-webview.ts b/src/renderer/src/components/browser-pane/browser-page-webview.ts index b8c44d849ad..798b1845048 100644 --- a/src/renderer/src/components/browser-pane/browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/browser-page-webview.ts @@ -1,5 +1,4 @@ import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences' -import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy' import { destroyPersistentWebview, registerPersistentWebview, @@ -11,30 +10,30 @@ export function ensureBrowserPageWebview({ container, inputLocked, webviewPartition, - allowWindowClose, resolveContainer }: { browserTabId: string container: HTMLDivElement inputLocked: boolean webviewPartition: string - allowWindowClose?: boolean resolveContainer: () => HTMLDivElement | null }): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null { let webview = webviewRegistry.get(browserTabId) let created = false let activeContainer = container + const parentDrifted = webview?.parentElement !== container // Why: a persisted guest must be torn down and rebuilt when its DOM parent // drifted (moving a across parents can recreate the guest document) // or when its partition no longer matches — Electron partitions are immutable - // after creation, so reuse would keep the stale session. Re-resolve the - // viewport container the teardown may have detached; bail if it is gone. - if ( - webview && - (webview.parentElement !== container || webview.getAttribute('partition') !== webviewPartition) - ) { - destroyPersistentWebview(browserTabId) + // after creation, so reuse would keep the stale session. Parent-drift repair + // preserves the newly replaced viewport; always verify the resolved container. + if (webview && (parentDrifted || webview.getAttribute('partition') !== webviewPartition)) { + if (parentDrifted) { + destroyPersistentWebview(browserTabId, { preserveViewport: true }) + } else { + destroyPersistentWebview(browserTabId) + } webview = undefined const refreshedContainer = resolveContainer() if (!refreshedContainer) { @@ -49,10 +48,6 @@ export function ensureBrowserPageWebview({ webview = document.createElement('webview') as Electron.WebviewTag webview.setAttribute('partition', webviewPartition) - if (allowWindowClose) { - // Why: main consumes and removes this marker in will-attach-webview before the guest sees any preload. - webview.setAttribute('preload', BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD) - } webview.setAttribute('allowpopups', '') // Why: Electron spreads the webpreferences keys verbatim, so the shared // camelCase attribute must stay intact for fullscreen containment to work. diff --git a/src/renderer/src/components/browser-pane/browser-page-zoom.ts b/src/renderer/src/components/browser-pane/browser-page-zoom.ts index 4a4f3924942..2a5233c5cf1 100644 --- a/src/renderer/src/components/browser-pane/browser-page-zoom.ts +++ b/src/renderer/src/components/browser-pane/browser-page-zoom.ts @@ -107,14 +107,6 @@ export function getBrowserPageZoomIndicatorState({ } } -export function dispatchBrowserPageZoomEvent(detail: BrowserPageZoomEventDetail): void { - window.dispatchEvent( - new CustomEvent(ORCA_BROWSER_PAGE_ZOOM_EVENT, { - detail - }) - ) -} - export function addBrowserPageZoomEventListener( callback: (detail: BrowserPageZoomEventDetail) => void ): () => void { diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index 51e3721cceb..f603839abd1 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -189,7 +189,10 @@ export function moveFocusToRendererBeforeWebviewDetach(webview: Electron.Webview moveFocusToRendererIfWebviewOwnsFocus(webview) } -export function destroyPersistentWebview(browserTabId: string): void { +export function destroyPersistentWebview( + browserTabId: string, + { preserveViewport = false }: { preserveViewport?: boolean } = {} +): void { const webview = webviewRegistry.get(browserTabId) // The guest is gone, so its user-applied zoom must not be inherited by a // later tab that reuses the id. @@ -197,7 +200,9 @@ export function destroyPersistentWebview(browserTabId: string): void { if (!webview) { // Why: the viewport can outlive a missing webview entry; tear it down on // explicit close paths so overlay slots do not leak parked shells. - removeBrowserPageViewport(browserTabId) + if (!preserveViewport) { + removeBrowserPageViewport(browserTabId) + } registeredWebContentsIds.delete(browserTabId) clearLiveBrowserUrl(browserTabId) return @@ -206,7 +211,9 @@ export function destroyPersistentWebview(browserTabId: string): void { moveFocusToRendererBeforeWebviewDetach(webview) webview.remove() unregisterPersistentWebview(browserTabId) - removeBrowserPageViewport(browserTabId) + if (!preserveViewport) { + removeBrowserPageViewport(browserTabId) + } registeredWebContentsIds.delete(browserTabId) clearLiveBrowserUrl(browserTabId) } diff --git a/src/renderer/src/components/confirmation-dialog-context.ts b/src/renderer/src/components/confirmation-dialog-context.ts new file mode 100644 index 00000000000..94a0ebc9984 --- /dev/null +++ b/src/renderer/src/components/confirmation-dialog-context.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react' + +// Keep the context component-free so Fast Refresh preserves its identity. + +export type ConfirmationDialogOptions = { + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + confirmVariant?: 'default' | 'destructive' +} + +export type ConfirmationDialogContextValue = ( + options: ConfirmationDialogOptions +) => Promise + +export const ConfirmationDialogContext = createContext(null) + +export function useConfirmationDialog(): ConfirmationDialogContextValue { + const confirm = useContext(ConfirmationDialogContext) + if (!confirm) { + throw new Error('useConfirmationDialog must be used inside ConfirmationDialogProvider') + } + return confirm +} diff --git a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts new file mode 100644 index 00000000000..952cb21e23d --- /dev/null +++ b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { createRequire } from 'node:module' +import { createElement, type ReactNode } from 'react' +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import * as contextModule from '@/components/confirmation-dialog-context' +import * as providerModule from '@/components/confirmation-dialog' +import { ConfirmationDialogProvider } from '@/components/confirmation-dialog' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' + +// react-refresh ships no types; take the one binding this needs. +const { isLikelyComponentType } = createRequire(import.meta.url)('react-refresh/runtime') as { + isLikelyComponentType: (value: unknown) => boolean +} + +describe('confirmation dialog Fast Refresh boundary', () => { + it('exports nothing from the provider module that invalidates the refresh boundary', () => { + expect(Object.keys(providerModule)).toEqual(['ConfirmationDialogProvider']) + expect(isLikelyComponentType(providerModule.ConfirmationDialogProvider)).toBe(true) + }) + + it('keeps the context and hook in a component-free module', () => { + const components = Object.entries(contextModule) + .filter(([, value]) => isLikelyComponentType(value)) + .map(([name]) => name) + + expect(components).toEqual([]) + expect(typeof contextModule.useConfirmationDialog).toBe('function') + }) + + it('resolves the hook against the context the provider publishes', () => { + const wrapper = ({ children }: { children: ReactNode }) => + createElement(ConfirmationDialogProvider, null, children) + const { result } = renderHook(() => useConfirmationDialog(), { wrapper }) + + expect(typeof result.current).toBe('function') + }) +}) diff --git a/src/renderer/src/components/confirmation-dialog.tsx b/src/renderer/src/components/confirmation-dialog.tsx index f13285c4907..3011078c33b 100644 --- a/src/renderer/src/components/confirmation-dialog.tsx +++ b/src/renderer/src/components/confirmation-dialog.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { @@ -9,27 +9,20 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + ConfirmationDialogContext, + type ConfirmationDialogContextValue, + type ConfirmationDialogOptions +} from '@/components/confirmation-dialog-context' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -type ConfirmationDialogOptions = { - title: string - description?: string - confirmLabel?: string - cancelLabel?: string - confirmVariant?: 'default' | 'destructive' -} - type ConfirmationDialogRequest = { id: number options: ConfirmationDialogOptions resolve: (confirmed: boolean) => void } -type ConfirmationDialogContextValue = (options: ConfirmationDialogOptions) => Promise - -const ConfirmationDialogContext = createContext(null) - export function ConfirmationDialogProvider({ children }: { @@ -116,11 +109,3 @@ export function ConfirmationDialogProvider({ ) } - -export function useConfirmationDialog(): ConfirmationDialogContextValue { - const confirm = useContext(ConfirmationDialogContext) - if (!confirm) { - throw new Error('useConfirmationDialog must be used inside ConfirmationDialogProvider') - } - return confirm -} diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 71632ac93e6..65c57ab2f4e 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -1,18 +1,9 @@ -import { useMemo } from 'react' -import { useAppStore } from '@/store' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, - type AgentStatusState, - type AgentType, - type MigrationUnsupportedPtyEntry +import type { + AgentStatusEntry, + AgentStatusState, + AgentType } from '../../../../shared/agent-status-types' -import type { Repo, Worktree, TerminalTab } from '../../../../shared/types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' - -// ─── Shared data types ──────────────────────────────────────────────────────── +import type { TerminalTab } from '../../../../shared/types' export type DashboardAgentRow = { /** Row identity. For 'subagent' rows this is a synthetic key (the child has @@ -37,171 +28,3 @@ export type DashboardAgentRow = { childCount: number } } - -// Why: the shape here is deliberately minimal. The per-card rendering pipeline -// is separate (WorktreeCardAgents + useWorktreeAgentRows read retained entries -// directly from the store). -export type DashboardWorktreeCard = { - repo: Repo - worktree: Worktree - agents: DashboardAgentRow[] -} - -export type DashboardProjectGroup = { - repo: Repo - worktrees: DashboardWorktreeCard[] -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -// Why: only surface agents that have reported state via a hook. A tab hosting -// a shell, a REPL before its first turn, or an agent we have no hook -// integration for will have no entry here — and that's correct. Agent rows -// represent *agent work in progress*, not "which terminals might contain an -// agent". -function buildAgentRowsForWorktree( - worktreeId: string, - tabsByWorktree: Record, - entriesByTabId: Map, - now: number -): DashboardAgentRow[] { - const tabs = tabsByWorktree[worktreeId] ?? [] - const rows: DashboardAgentRow[] = [] - - for (const tab of tabs) { - const explicitEntries = entriesByTabId.get(tab.id) ?? [] - for (const entry of explicitEntries) { - // Why: decay stale working/blocked/waiting entries to 'idle' when the hook - // stream has gone silent past AGENT_STATUS_STALE_AFTER_MS (30 min TTL). - // Without this, an agent process that exited without sending a final - // update would remain "working" forever — the Active/Blocked filters and - // the sidebar's running-agents count would mislead the user into chasing - // dead work. `done` is terminal and must NOT decay to idle: retention - // (collectRetainedAgentsOnDisappear) only retains rows whose prev state - // was 'done', so decaying a stale done → idle would silently drop the - // completion signal when the entry later disappears. - const isFresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) - const shouldDecay = - !isFresh && - (entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting') - rows.push({ - paneKey: entry.paneKey, - entry, - tab, - agentType: entry.agentType ?? 'unknown', - rowSource: 'live', - state: shouldDecay ? 'idle' : entry.state, - // Why: the oldest stateHistory entry's startedAt is the agent's original - // "first seen" timestamp. When history is empty the entry has never - // transitioned state, so stateStartedAt (the moment the current — and - // only — state began) is the true first-seen timestamp. Do NOT fall back - // to updatedAt: it advances on every tool/prompt ping within the same - // state, which would corrupt oldest-first ordering and the "started … - // ago" display for long-running agents between state transitions. See - // agent-status.ts (stateStartedAt carry-forward on same-state pings). - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt - }) - } - } - - return rows -} - -function buildDashboardData( - repos: Repo[], - worktreesByRepo: Record, - tabsByWorktree: Record, - agentStatusByPaneKey: Record, - migrationUnsupportedByPtyId: Record, - now: number -): DashboardProjectGroup[] { - // Why: build a tabId -> entries index once per computation instead of - // re-scanning every agent status entry inside the per-tab loop. paneKey is - // formatted as `${tabId}:${leafId}`; parsePaneKey also drops legacy numeric - // suffixes so stale rows do not remain routable after pane replay. - const entriesByTabId = new Map() - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - continue - } - const bucket = entriesByTabId.get(parsed.tabId) - if (bucket) { - bucket.push(entry) - } else { - entriesByTabId.set(parsed.tabId, [entry]) - } - } - for (const unsupported of Object.values(migrationUnsupportedByPtyId)) { - const entry = migrationUnsupportedToAgentStatusEntry(unsupported) - if (!entry) { - continue - } - const parsed = parsePaneKey(entry.paneKey) - if (!parsed) { - continue - } - const bucket = entriesByTabId.get(parsed.tabId) - if (bucket) { - bucket.push(entry) - } else { - entriesByTabId.set(parsed.tabId, [entry]) - } - } - - return repos.map((repo) => { - const worktrees = (worktreesByRepo[repo.id] ?? []) - .filter((w) => !w.isArchived) - .map((worktree) => { - const agents = buildAgentRowsForWorktree(worktree.id, tabsByWorktree, entriesByTabId, now) - return { repo, worktree, agents } satisfies DashboardWorktreeCard - }) - - return { repo, worktrees } satisfies DashboardProjectGroup - }) -} - -// ─── Hook ───────────────────────────────────────────────────────────────────── - -/** - * Cross-worktree aggregate of live agent rows. - * - * Not used to render anything directly — the inline list reads its own - * worktree-scoped slice via useWorktreeAgentRows. - */ -export function useDashboardData(): DashboardProjectGroup[] { - const repos = useAppStore((s) => s.repos) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) - const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) - // Why: agentStatusEpoch is included in the dependency array (but not in the - // computation itself) so the memo recomputes when freshness boundaries expire, - // even if no new PTY data arrives. - const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) - - return useMemo( - // Why: Date.now() is read inside the memo (not as a dep) so stale-decay - // recalculates whenever agentStatusEpoch ticks. The epoch bumps when the - // freshness boundary crosses, driving re-evaluation without coupling to - // wall-clock time directly. - () => - buildDashboardData( - repos, - worktreesByRepo, - tabsByWorktree, - agentStatusByPaneKey, - migrationUnsupportedByPtyId, - Date.now() - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - repos, - worktreesByRepo, - tabsByWorktree, - agentStatusByPaneKey, - migrationUnsupportedByPtyId, - agentStatusEpoch - ] - ) -} diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 1d8486becf5..7985c13befa 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -43,6 +43,10 @@ export function DictationController() { const erroredSessionIdsRef = useRef(new Set()) const intentionalTargetCancellationRef = useRef(false) const insertedFinalTranscriptRef = useRef('') + // Why: push-to-talk restarts capture per utterance; toast once per preference, + // not once per press, while the selected mic stays gone. + const micFallbackNotifiedForRef = useRef(null) + const stopDictationRef = useRef<(() => void) | null>(null) const drainStoppedSession = useCallback((sessionId: string) => { void waitForStoppedSession(sessionId, stoppedSessionIdsRef, stoppedResolversRef) @@ -131,8 +135,44 @@ export function DictationController() { try { // Why: worker startup can take seconds after idle teardown. Capture first // and buffer locally so speech during "Starting..." is not discarded. - await startCapture({ bufferAudio: true, sessionId }) + const preferredMicrophoneDeviceId = settings?.voice?.microphoneDeviceId ?? null + const captureResult = await startCapture({ + bufferAudio: true, + sessionId, + microphoneDeviceId: preferredMicrophoneDeviceId, + microphoneDeviceLabel: settings?.voice?.microphoneDeviceLabel ?? null, + onCaptureLost: () => { + if (dictationRunRef.current !== runId) { + return + } + toast.message( + translate( + 'auto.components.dictation.DictationController.micDisconnected', + 'Microphone disconnected. Dictation stopped.' + ) + ) + stopDictationRef.current?.() + } + }) captureStarted = true + if (captureResult?.fellBackToDefaultMicrophone) { + // Why: a stop requested during startup tears this capture down below, so the + // notice would describe a fallback that never records anything. + if ( + !stopRequestedDuringStartRef.current && + micFallbackNotifiedForRef.current !== preferredMicrophoneDeviceId + ) { + micFallbackNotifiedForRef.current = preferredMicrophoneDeviceId + toast.message( + translate( + 'auto.components.dictation.DictationController.micFallback', + 'Selected microphone unavailable. Using system default.' + ) + ) + } + } else { + micFallbackNotifiedForRef.current = null + } if (stopRequestedDuringStartRef.current) { stopCapture({ preserveBufferedAudio: true }) } @@ -233,6 +273,10 @@ export function DictationController() { await finishDictationSession(sessionId) }, [finishDictationSession, setDictationState, stopCapture]) + // Why: capture-loss fires from a stream opened before stopDictation exists; + // route through a ref so the two callbacks do not depend on each other. + stopDictationRef.current = () => void stopDictation() + // Toggle mode: use IPC from main process (before-input-event intercepts // the keyDown so Cmd+E doesn't reach xterm or trigger system shortcuts). useEffect(() => { diff --git a/src/renderer/src/components/dictation/microphone-devices.test.ts b/src/renderer/src/components/dictation/microphone-devices.test.ts new file mode 100644 index 00000000000..d449e758d3d --- /dev/null +++ b/src/renderer/src/components/dictation/microphone-devices.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultVoiceSettings } from '../../../../shared/constants' +import { + buildAudioCaptureConstraints, + buildVoiceMicrophoneSelectOptions, + isMicrophoneDeviceConstraintError, + listVoiceMicrophoneDevices, + microphoneDeviceIdFromSelectValue, + microphoneSelectValueFromDeviceId, + normalizeMicrophoneDeviceId, + openMicrophoneCaptureStream, + resolveMicrophoneDevice, + SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE +} from './microphone-devices' + +describe('normalizeMicrophoneDeviceId', () => { + it('treats empty and whitespace as system default', () => { + expect(normalizeMicrophoneDeviceId(null)).toBeNull() + expect(normalizeMicrophoneDeviceId(undefined)).toBeNull() + expect(normalizeMicrophoneDeviceId('')).toBeNull() + expect(normalizeMicrophoneDeviceId(' ')).toBeNull() + }) + + it('treats Chromium aggregate ids as system default', () => { + expect(normalizeMicrophoneDeviceId('default')).toBeNull() + expect(normalizeMicrophoneDeviceId('communications')).toBeNull() + }) + + it('preserves non-empty device ids', () => { + expect(normalizeMicrophoneDeviceId('abc-123')).toBe('abc-123') + }) +}) + +describe('getDefaultVoiceSettings microphone fields', () => { + it('defaults microphone selection to system default', () => { + const voice = getDefaultVoiceSettings() + expect(voice.microphoneDeviceId).toBeNull() + expect(voice.microphoneDeviceLabel).toBeNull() + }) +}) + +describe('buildAudioCaptureConstraints', () => { + it('omits deviceId for system default', () => { + expect(buildAudioCaptureConstraints(null)).toEqual({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }) + }) + + it('pins exact deviceId when a mic is selected', () => { + expect(buildAudioCaptureConstraints('usb-mic-1')).toEqual({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + deviceId: { exact: 'usb-mic-1' } + } + }) + }) + + it('does not pin the aggregate default id', () => { + expect(buildAudioCaptureConstraints('default')).toEqual(buildAudioCaptureConstraints(null)) + }) +}) + +describe('isMicrophoneDeviceConstraintError', () => { + it('matches OverconstrainedError and NotFoundError only', () => { + expect(isMicrophoneDeviceConstraintError(new DOMException('x', 'OverconstrainedError'))).toBe( + true + ) + expect(isMicrophoneDeviceConstraintError(new DOMException('x', 'NotFoundError'))).toBe(true) + expect(isMicrophoneDeviceConstraintError(new DOMException('x', 'NotAllowedError'))).toBe(false) + expect(isMicrophoneDeviceConstraintError(new Error('boom'))).toBe(false) + expect(isMicrophoneDeviceConstraintError(null)).toBe(false) + }) +}) + +describe('resolveMicrophoneDevice', () => { + const devices = [ + { deviceId: 'mic-1', label: 'Built-in' }, + { deviceId: 'mic-2', label: 'AirPods' } + ] + + it('reports system default when nothing is preferred', () => { + expect( + resolveMicrophoneDevice({ devices, preferredDeviceId: null, preferredDeviceLabel: null }) + ).toEqual({ deviceId: null, kind: 'system-default' }) + }) + + it('matches by id first', () => { + expect( + resolveMicrophoneDevice({ + devices, + preferredDeviceId: 'mic-2', + preferredDeviceLabel: 'Built-in' + }) + ).toEqual({ deviceId: 'mic-2', kind: 'exact' }) + }) + + it('heals a re-salted id via a unique label match', () => { + expect( + resolveMicrophoneDevice({ + devices, + preferredDeviceId: 'stale-id', + preferredDeviceLabel: ' airpods ' + }) + ).toEqual({ deviceId: 'mic-2', kind: 'relabeled' }) + }) + + it('refuses to guess between duplicate labels', () => { + expect( + resolveMicrophoneDevice({ + devices: [ + { deviceId: 'a', label: 'Yeti' }, + { deviceId: 'b', label: 'Yeti' } + ], + preferredDeviceId: 'stale-id', + preferredDeviceLabel: 'Yeti' + }) + ).toEqual({ deviceId: null, kind: 'missing' }) + }) + + it('does not treat an unreadable list as a missing device', () => { + expect( + resolveMicrophoneDevice({ + devices: null, + preferredDeviceId: 'mic-9', + preferredDeviceLabel: null + }) + ).toEqual({ deviceId: 'mic-9', kind: 'unknown' }) + expect( + resolveMicrophoneDevice({ + devices: [], + preferredDeviceId: 'mic-9', + preferredDeviceLabel: null + }) + ).toEqual({ deviceId: 'mic-9', kind: 'unknown' }) + }) +}) + +describe('openMicrophoneCaptureStream', () => { + it('opens with preferred device when available', async () => { + const stream = { id: 'preferred' } as unknown as MediaStream + const getUserMedia = vi.fn().mockResolvedValue(stream) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'mic-a', + getUserMedia + }) + ).resolves.toEqual({ + stream, + fellBackToDefaultMicrophone: false, + usedDeviceId: 'mic-a' + }) + + expect(getUserMedia).toHaveBeenCalledTimes(1) + expect(getUserMedia).toHaveBeenCalledWith(buildAudioCaptureConstraints('mic-a')) + }) + + it('skips the doomed attempt when enumeration proves the device is gone', async () => { + const fallbackStream = { id: 'default' } as unknown as MediaStream + const getUserMedia = vi.fn().mockResolvedValue(fallbackStream) + const enumerateDevices = vi + .fn() + .mockResolvedValue([{ deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in' }]) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'unplugged', + preferredDeviceLabel: 'Unplugged Mic', + getUserMedia, + enumerateDevices + }) + ).resolves.toEqual({ + stream: fallbackStream, + fellBackToDefaultMicrophone: true, + usedDeviceId: null + }) + + expect(getUserMedia).toHaveBeenCalledTimes(1) + expect(getUserMedia).toHaveBeenCalledWith(buildAudioCaptureConstraints(null)) + }) + + it('captures on the relabeled device after an id rotation', async () => { + const stream = { id: 'healed' } as unknown as MediaStream + const getUserMedia = vi.fn().mockResolvedValue(stream) + const enumerateDevices = vi + .fn() + .mockResolvedValue([{ deviceId: 'fresh-id', kind: 'audioinput', label: 'Yeti' }]) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'stale-id', + preferredDeviceLabel: 'Yeti', + getUserMedia, + enumerateDevices + }) + ).resolves.toEqual({ + stream, + fellBackToDefaultMicrophone: false, + usedDeviceId: 'fresh-id' + }) + + expect(getUserMedia).toHaveBeenCalledWith(buildAudioCaptureConstraints('fresh-id')) + }) + + it('still falls back when the device vanishes after enumeration', async () => { + const fallbackStream = { id: 'default' } as unknown as MediaStream + const getUserMedia = vi + .fn() + .mockRejectedValueOnce(new DOMException('gone', 'OverconstrainedError')) + .mockResolvedValueOnce(fallbackStream) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'missing-mic', + getUserMedia + }) + ).resolves.toEqual({ + stream: fallbackStream, + fellBackToDefaultMicrophone: true, + usedDeviceId: null + }) + + expect(getUserMedia).toHaveBeenNthCalledWith(1, buildAudioCaptureConstraints('missing-mic')) + expect(getUserMedia).toHaveBeenNthCalledWith(2, buildAudioCaptureConstraints(null)) + }) + + it('keeps the preference when the device list cannot be read', async () => { + const stream = { id: 'preferred' } as unknown as MediaStream + const getUserMedia = vi.fn().mockResolvedValue(stream) + const enumerateDevices = vi.fn().mockRejectedValue(new Error('nope')) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'mic-a', + getUserMedia, + enumerateDevices + }) + ).resolves.toMatchObject({ usedDeviceId: 'mic-a', fellBackToDefaultMicrophone: false }) + }) + + it('does not fall back on permission errors', async () => { + const getUserMedia = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + + await expect( + openMicrophoneCaptureStream({ + preferredDeviceId: 'mic-a', + getUserMedia + }) + ).rejects.toMatchObject({ name: 'NotAllowedError' }) + + expect(getUserMedia).toHaveBeenCalledTimes(1) + }) +}) + +describe('listVoiceMicrophoneDevices', () => { + it('keeps audio inputs and fills empty labels', () => { + expect( + listVoiceMicrophoneDevices([ + { deviceId: 'out-1', kind: 'audiooutput', label: 'Speakers' }, + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in' }, + { deviceId: 'mic-2', kind: 'audioinput', label: '' }, + { deviceId: '', kind: 'audioinput', label: 'Empty id' } + ]) + ).toEqual([ + { deviceId: 'mic-1', label: 'Built-in' }, + { deviceId: 'mic-2', label: 'Microphone 2' } + ]) + }) + + it('drops the Chromium default and communications aliases', () => { + expect( + listVoiceMicrophoneDevices([ + { deviceId: 'default', kind: 'audioinput', label: 'Default - Built-in Microphone' }, + { deviceId: 'communications', kind: 'audioinput', label: 'Communications - Headset' }, + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Microphone' } + ]) + ).toEqual([{ deviceId: 'mic-1', label: 'Built-in Microphone' }]) + }) +}) + +describe('microphone select values', () => { + it('round-trips system default and concrete device ids', () => { + expect(microphoneSelectValueFromDeviceId(null)).toBe(SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE) + expect(microphoneDeviceIdFromSelectValue(SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE)).toBeNull() + expect(microphoneDeviceIdFromSelectValue('usb-1')).toBe('usb-1') + }) +}) + +describe('buildVoiceMicrophoneSelectOptions', () => { + const labels = { systemDefaultLabel: 'System default', unavailableSuffix: 'unavailable' } + + it('includes system default and available devices', () => { + expect( + buildVoiceMicrophoneSelectOptions({ + devices: [{ deviceId: 'mic-1', label: 'Built-in' }], + devicesKnown: true, + preferredDeviceId: null, + preferredDeviceLabel: null, + ...labels + }) + ).toEqual({ + options: [ + { value: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE, label: 'System default' }, + { value: 'mic-1', label: 'Built-in' } + ], + selectedValue: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE + }) + }) + + it('keeps a missing preferred device as an unavailable option', () => { + expect( + buildVoiceMicrophoneSelectOptions({ + devices: [{ deviceId: 'mic-1', label: 'Built-in' }], + devicesKnown: true, + preferredDeviceId: 'airpods', + preferredDeviceLabel: 'AirPods', + ...labels + }) + ).toEqual({ + options: [ + { value: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE, label: 'System default' }, + { value: 'mic-1', label: 'Built-in' }, + { value: 'airpods', label: 'AirPods (unavailable)', unavailable: true } + ], + selectedValue: 'airpods' + }) + }) + + it('selects the relabeled device instead of showing a ghost row', () => { + expect( + buildVoiceMicrophoneSelectOptions({ + devices: [{ deviceId: 'fresh-id', label: 'AirPods' }], + devicesKnown: true, + preferredDeviceId: 'stale-id', + preferredDeviceLabel: 'AirPods', + ...labels + }) + ).toEqual({ + options: [ + { value: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE, label: 'System default' }, + { value: 'fresh-id', label: 'AirPods' } + ], + selectedValue: 'fresh-id' + }) + }) + + it('does not label a device unavailable before the list is known', () => { + expect( + buildVoiceMicrophoneSelectOptions({ + devices: [], + devicesKnown: false, + preferredDeviceId: 'airpods', + preferredDeviceLabel: 'AirPods', + ...labels + }) + ).toEqual({ + options: [ + { value: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE, label: 'System default' }, + { value: 'airpods', label: 'AirPods' } + ], + selectedValue: 'airpods' + }) + }) +}) diff --git a/src/renderer/src/components/dictation/microphone-devices.ts b/src/renderer/src/components/dictation/microphone-devices.ts new file mode 100644 index 00000000000..9f97a6f286c --- /dev/null +++ b/src/renderer/src/components/dictation/microphone-devices.ts @@ -0,0 +1,260 @@ +export type VoiceMicrophoneDevice = { + deviceId: string + label: string +} + +export type VoiceMicrophoneSelectOption = { + value: string + label: string + unavailable?: boolean +} + +export type MicrophoneResolutionKind = + | 'system-default' + | 'exact' + | 'relabeled' + | 'missing' + | 'unknown' + +export type MicrophoneResolution = { + deviceId: string | null + kind: MicrophoneResolutionKind +} + +export type OpenMicrophoneCaptureStreamResult = { + stream: MediaStream + fellBackToDefaultMicrophone: boolean + usedDeviceId: string | null +} + +export type EnumeratedDevice = Pick + +export const SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE = 'system-default' + +// Why: Chromium synthesizes these ids as aliases for whatever the OS default is, +// so pinning one behaves exactly like "system default" and defeats the setting. +const AGGREGATE_DEVICE_IDS = new Set(['default', 'communications']) + +const BASE_AUDIO_CONSTRAINTS: MediaTrackConstraints = { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true +} + +export function normalizeMicrophoneDeviceId(deviceId: string | null | undefined): string | null { + if (typeof deviceId !== 'string') { + return null + } + const trimmed = deviceId.trim() + if (trimmed.length === 0) { + return null + } + return AGGREGATE_DEVICE_IDS.has(trimmed) ? null : trimmed +} + +export function buildAudioCaptureConstraints( + deviceId: string | null | undefined +): MediaStreamConstraints { + const preferredDeviceId = normalizeMicrophoneDeviceId(deviceId) + if (!preferredDeviceId) { + return { audio: { ...BASE_AUDIO_CONSTRAINTS } } + } + return { + audio: { + ...BASE_AUDIO_CONSTRAINTS, + deviceId: { exact: preferredDeviceId } + } + } +} + +export function isMicrophoneDeviceConstraintError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const name = 'name' in error ? String(error.name) : '' + // Why: exact deviceId fails as OverconstrainedError; unplugged devices often surface as NotFoundError. + return name === 'OverconstrainedError' || name === 'NotFoundError' +} + +export function listVoiceMicrophoneDevices( + devices: readonly EnumeratedDevice[] +): VoiceMicrophoneDevice[] { + return devices + .filter( + (device) => + device.kind === 'audioinput' && normalizeMicrophoneDeviceId(device.deviceId) !== null + ) + .map((device, index) => ({ + deviceId: device.deviceId.trim(), + label: device.label.trim() || `Microphone ${index + 1}` + })) +} + +function findSoleDeviceByLabel( + devices: readonly VoiceMicrophoneDevice[], + label: string | null | undefined +): VoiceMicrophoneDevice | null { + const wanted = label?.trim().toLowerCase() + if (!wanted) { + return null + } + const matches = devices.filter((device) => device.label.trim().toLowerCase() === wanted) + // Why: identical labels (two of the same headset) give no basis to pick one. + return matches.length === 1 ? (matches[0] ?? null) : null +} + +/** + * Resolves a stored preference against the live device list. Pass `devices: null` + * when the list could not be read — that is not evidence the device is gone. + */ +export function resolveMicrophoneDevice(args: { + devices: readonly VoiceMicrophoneDevice[] | null + preferredDeviceId: string | null | undefined + preferredDeviceLabel: string | null | undefined +}): MicrophoneResolution { + const preferredDeviceId = normalizeMicrophoneDeviceId(args.preferredDeviceId) + if (!preferredDeviceId) { + return { deviceId: null, kind: 'system-default' } + } + // Why: before mic permission, enumeration returns only blank placeholders, so an + // empty list means "cannot tell yet" rather than "unplugged". + if (!args.devices || args.devices.length === 0) { + return { deviceId: preferredDeviceId, kind: 'unknown' } + } + if (args.devices.some((device) => device.deviceId === preferredDeviceId)) { + return { deviceId: preferredDeviceId, kind: 'exact' } + } + // Why: Chromium re-salts device ids per profile+origin, so a surviving label match + // heals a preference whose id rotated instead of stranding it forever. + const relabeled = findSoleDeviceByLabel(args.devices, args.preferredDeviceLabel) + if (relabeled) { + return { deviceId: relabeled.deviceId, kind: 'relabeled' } + } + return { deviceId: null, kind: 'missing' } +} + +async function enumerateMicrophonesOrNull( + enumerateDevices: (() => Promise) | undefined +): Promise { + if (!enumerateDevices) { + return null + } + try { + return listVoiceMicrophoneDevices(await enumerateDevices()) + } catch { + return null + } +} + +export async function openMicrophoneCaptureStream(args: { + preferredDeviceId: string | null | undefined + preferredDeviceLabel?: string | null + getUserMedia: (constraints: MediaStreamConstraints) => Promise + enumerateDevices?: () => Promise +}): Promise { + const preferredDeviceId = normalizeMicrophoneDeviceId(args.preferredDeviceId) + if (!preferredDeviceId) { + return { + stream: await args.getUserMedia(buildAudioCaptureConstraints(null)), + fellBackToDefaultMicrophone: false, + usedDeviceId: null + } + } + + const resolution = resolveMicrophoneDevice({ + devices: await enumerateMicrophonesOrNull(args.enumerateDevices), + preferredDeviceId, + preferredDeviceLabel: args.preferredDeviceLabel + }) + + // Why: enumeration is a cheap cached lookup, so a known-missing device skips the + // failed getUserMedia round trip that would otherwise clip the first words. + if (resolution.kind === 'missing') { + return { + stream: await args.getUserMedia(buildAudioCaptureConstraints(null)), + fellBackToDefaultMicrophone: true, + usedDeviceId: null + } + } + + const targetDeviceId = resolution.deviceId ?? preferredDeviceId + try { + const stream = await args.getUserMedia(buildAudioCaptureConstraints(targetDeviceId)) + return { stream, fellBackToDefaultMicrophone: false, usedDeviceId: targetDeviceId } + } catch (error) { + // Why: still needed — the device can vanish between enumeration and capture. + if (!isMicrophoneDeviceConstraintError(error)) { + throw error + } + return { + stream: await args.getUserMedia(buildAudioCaptureConstraints(null)), + fellBackToDefaultMicrophone: true, + usedDeviceId: null + } + } +} + +export function microphoneSelectValueFromDeviceId(deviceId: string | null | undefined): string { + return normalizeMicrophoneDeviceId(deviceId) ?? SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE +} + +export function microphoneDeviceIdFromSelectValue(value: string): string | null { + if (value === SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE) { + return null + } + return normalizeMicrophoneDeviceId(value) +} + +export function buildVoiceMicrophoneSelectOptions(args: { + devices: readonly VoiceMicrophoneDevice[] + /** false until enumeration has produced a usable list */ + devicesKnown: boolean + preferredDeviceId: string | null | undefined + preferredDeviceLabel: string | null | undefined + systemDefaultLabel: string + unavailableSuffix: string +}): { options: VoiceMicrophoneSelectOption[]; selectedValue: string } { + const options: VoiceMicrophoneSelectOption[] = [ + { + value: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE, + label: args.systemDefaultLabel + }, + ...args.devices.map((device) => ({ + value: device.deviceId, + label: device.label + })) + ] + + const resolution = resolveMicrophoneDevice({ + devices: args.devicesKnown ? args.devices : null, + preferredDeviceId: args.preferredDeviceId, + preferredDeviceLabel: args.preferredDeviceLabel + }) + + if (resolution.kind === 'system-default') { + return { options, selectedValue: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE } + } + if (resolution.kind === 'exact' || resolution.kind === 'relabeled') { + return { options, selectedValue: resolution.deviceId ?? SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE } + } + + const preferredDeviceId = normalizeMicrophoneDeviceId(args.preferredDeviceId) + if (!preferredDeviceId) { + return { options, selectedValue: SYSTEM_DEFAULT_MICROPHONE_SELECT_VALUE } + } + const cachedLabel = args.preferredDeviceLabel?.trim() + const baseLabel = cachedLabel && cachedLabel.length > 0 ? cachedLabel : preferredDeviceId + // Why: only call it unavailable once we have actually seen the device list — + // an un-enumerated list would otherwise flag every mic as missing. + options.push( + resolution.kind === 'missing' + ? { + value: preferredDeviceId, + label: `${baseLabel} (${args.unavailableSuffix})`, + unavailable: true + } + : { value: preferredDeviceId, label: baseLabel } + ) + return { options, selectedValue: preferredDeviceId } +} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index eb8afa2d5c9..52b079dc526 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -33,7 +33,9 @@ import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' const MonacoEditor = lazy(() => import('./MonacoEditor')) const DiffViewer = lazy(() => import('./DiffViewer')) const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer')) -const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor')) +const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), { + reloadKey: 'rich-markdown-editor' +}) const MarkdownPreview = lazy(() => import('./MarkdownPreview')) const ImageViewer = lazy(() => import('./ImageViewer')) const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index d977ec5ae9b..a176594bc80 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -134,8 +134,7 @@ vi.mock('./ReviewNotesSendMenuContent', () => ({ vi.mock('@/lib/active-agent-note-send', () => ({ activeAgentNotesSendFailureMessage: (status: string) => status, getActiveTerminalNoteTarget: () => null, - sendNotesToActiveAgentSession: vi.fn(), - useCanSendNotesToActiveTerminal: () => false + sendNotesToActiveAgentSession: vi.fn() })) vi.mock('sonner', () => ({ diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 2007b142702..92e7e21da80 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -91,8 +91,7 @@ vi.mock('@/lib/active-agent-note-send', () => ({ status: string, options: { explicitTarget?: boolean } = {} ) => (options.explicitTarget ? `selected:${status}` : status), - sendNotesToActiveAgentSession: harness.sendNotesToActiveAgentSession, - useCanSendNotesToActiveTerminal: () => true + sendNotesToActiveAgentSession: harness.sendNotesToActiveAgentSession })) vi.mock('@/lib/notes-send-agent-targets', () => ({ diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx new file mode 100644 index 00000000000..a9ac63ac6b0 --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.lazy-chunk.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { + clearLazyChunkBreadcrumbDedupeForTest, + RichMarkdownErrorBoundary +} from './RichMarkdownErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) +const recordBreadcrumbMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: recordBreadcrumbMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +function BoundaryHarness({ + children, + boundaryKey = 'pane-a' +}: { + children: ReactNode + boundaryKey?: string +}): ReactElement { + return ( + Loading...
}> + + {children} + + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +const CORRUPT_CHUNK_PARSE_ERROR = "Unexpected token ':'" + +describe('RichMarkdownErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + recordBreadcrumbMock.mockReset() + clearLazyChunkBreadcrumbDedupeForTest() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE) + const LazyRejectingImport = lazyWithRetry( + () => Promise.reject(new SyntaxError(CORRUPT_CHUNK_PARSE_ERROR)), + { retries: 0, reloadKey: 'rich-markdown-editor' } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.textContent).toContain('rich markdown editor') + expect(reportCrashMock).not.toHaveBeenCalled() + expect(recordBreadcrumbMock).toHaveBeenCalledWith('lazy_chunk_boundary_degraded', { + boundaryId: 'editor.rich-markdown', + reloadKey: 'rich-markdown-editor', + cause: `SyntaxError: ${CORRUPT_CHUNK_PARSE_ERROR}` + }) + }) + + it('records the degraded breadcrumb once across repeated Retry clicks', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE) + const LazyRejectingImport = lazyWithRetry( + () => Promise.reject(new SyntaxError(CORRUPT_CHUNK_PARSE_ERROR)), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + for (let click = 0; click < 3; click += 1) { + const retry = container?.querySelector('button') + expect(retry).not.toBeNull() + await act(async () => { + retry?.click() + }) + await flushReactWork() + } + + expect(container?.textContent).toContain('rich markdown editor') + expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1) + }) + + it('records the degraded breadcrumb once across boundary remounts', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE) + const LazyRejectingImport = lazyWithRetry( + () => Promise.reject(new SyntaxError("Unexpected token '<'")), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + for (const boundaryKey of ['pane-a', 'pane-b', 'pane-c']) { + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + } + + expect(container?.textContent).toContain('rich markdown editor') + expect(recordBreadcrumbMock).toHaveBeenCalledTimes(1) + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenEditor(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.textContent).toContain('rich markdown editor') + expect(recordBreadcrumbMock).not.toHaveBeenCalled() + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'editor.rich-markdown', + surface: 'rich-markdown-editor', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx index 105be54524f..7ca68fd9415 100644 --- a/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx +++ b/src/renderer/src/components/editor/RichMarkdownErrorBoundary.tsx @@ -1,4 +1,6 @@ import React from 'react' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import { translate } from '@/i18n/i18n' @@ -12,6 +14,21 @@ type State = { fileId: string } +// Module scope deduplicates breadcrumbs across file-keyed boundary remounts. +const recordedLazyChunkCauses = new Set() + +export function clearLazyChunkBreadcrumbDedupeForTest(): void { + recordedLazyChunkCauses.clear() +} + +function describeLazyChunkCause(error: Error): string { + const cause = (error as { cause?: unknown }).cause + if (cause instanceof Error) { + return `${cause.name}: ${cause.message}` + } + return cause === undefined ? 'unknown' : String(cause) +} + // Why: a thrown exception inside the TipTap/ProseMirror render or in the // effect that runs `setContent` + empty-list repair on external-reload // would escape to the React root and — without this boundary — cause React @@ -38,6 +55,18 @@ export class RichMarkdownErrorBoundary extends React.Component { componentDidCatch(error: Error, info: React.ErrorInfo): void { console.error('[RichMarkdownEditor] render crash contained by boundary', error, info) + if (isLazyChunkLoadError(error)) { + const cause = describeLazyChunkCause(error) + if (!recordedLazyChunkCauses.has(cause)) { + recordedLazyChunkCauses.add(cause) + recordRendererCrashBreadcrumb('lazy_chunk_boundary_degraded', { + boundaryId: 'editor.rich-markdown', + reloadKey: error.reloadKey, + cause + }) + } + return + } void reportReactErrorBoundaryCrash({ boundaryId: 'editor.rich-markdown', surface: 'rich-markdown-editor', diff --git a/src/renderer/src/components/editor/markdown-round-trip.ts b/src/renderer/src/components/editor/markdown-round-trip.ts index fcfa39efdc0..2655ca225b3 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.ts @@ -7,11 +7,6 @@ import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-ht const roundTripCache = new Map() const MAX_CACHE_ENTRIES = 20 -export function canRoundTripRichMarkdown(content: string): boolean { - const output = getRichMarkdownRoundTripOutput(content) - return output !== null && normalizeMarkdown(content) === normalizeMarkdown(output) -} - export function getRichMarkdownRoundTripOutput(content: string): string | null { const cached = roundTripCache.get(content) if (cached !== undefined) { @@ -59,7 +54,3 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null { return output } - -function normalizeMarkdown(content: string): string { - return content.replace(/\r\n/g, '\n').trimEnd() -} diff --git a/src/renderer/src/components/emulator-pane/emulator-keyboard-paste.ts b/src/renderer/src/components/emulator-pane/emulator-keyboard-paste.ts index ad6a371b334..dd641a83a52 100644 --- a/src/renderer/src/components/emulator-pane/emulator-keyboard-paste.ts +++ b/src/renderer/src/components/emulator-pane/emulator-keyboard-paste.ts @@ -57,10 +57,6 @@ export type PasteTextIntoEmulatorKeyboardOptions = { text: string } -export function getEmulatorKeyboardPasteByteLength(text: string): number { - return measureClipboardTextByteLength(text).byteLength -} - export function buildEmulatorKeyboardPastePlan( text: string, options: { diff --git a/src/renderer/src/components/emulator-pane/emulator-pane-types.ts b/src/renderer/src/components/emulator-pane/emulator-pane-types.ts index bd2b4589f48..80da92fc471 100644 --- a/src/renderer/src/components/emulator-pane/emulator-pane-types.ts +++ b/src/renderer/src/components/emulator-pane/emulator-pane-types.ts @@ -70,5 +70,3 @@ export function pickDefaultDevice(devices: SimulatorDeviceRow[]): SimulatorDevic null ) } - -export const isMacOs = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx index a4730f59e92..ca84d98b744 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/lib/react-error-boundary-reporting', () => ({ })) const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' +const LANDED_RELOAD_GUARD_VALUE = 'doc-before-the-reload' globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -60,7 +61,7 @@ describe('RecoverableRenderErrorBoundary lazy chunk containment', () => { }) it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { - window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + window.sessionStorage.setItem(RELOAD_GUARD_KEY, LANDED_RELOAD_GUARD_VALUE) const LazyRejectingImport = lazyWithRetry( () => Promise.reject( diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 6eaab34ec3e..4aa11354059 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -28,7 +28,11 @@ describe('GitHubItemDialog source host boundaries', () => { it('routes reviewer metadata and reviewer mutations through the task source context', () => { const source = componentSource('GitHubItemDialog.tsx') - const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + const section = sourceBetween( + source, + 'function PRReviewersPanel', + 'const WORK_ITEM_DETAILS_CACHE_MAX' + ) expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') expect(section).toContain('useRepoAssigneesBySlug(') @@ -50,11 +54,7 @@ describe('GitHubItemDialog source host boundaries', () => { it('routes edit metadata through the same task source as issue mutations', () => { const source = componentSource('GitHubItemDialog.tsx') const section = sourceBetween(source, 'function GHEditSection', 'const hasAttachedWorkspace') - const helperSection = sourceBetween( - source, - 'function getGitHubMutationSettings', - 'function GitHubLabelsSettingsLink' - ) + const helperSection = componentSource('github/github-work-item-edit-mutations.ts') expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') expect(section).toContain('useRepoLabels(') @@ -119,11 +119,7 @@ describe('GitHubItemDialog source host boundaries', () => { it('routes PR file viewed mutations through the task source context', () => { const source = componentSource('GitHubItemDialog.tsx') - const helperSection = sourceBetween( - source, - 'function setPRFileViewedForRepo', - 'function PRViewedCheckbox' - ) + const helperSection = componentSource('github/github-work-item-comment-mutations.ts') const changeSection = sourceBetween( source, 'const handlePRFileViewedChange = useCallback', @@ -141,7 +137,7 @@ describe('GitHubItemDialog source host boundaries', () => { }) it('routes comment mutations through runtime source context when needed', () => { - const source = componentSource('GitHubItemDialog.tsx') + const source = componentSource('github/github-work-item-comment-mutations.ts') const helperSection = sourceBetween( source, 'function addIssueCommentForRepo', @@ -160,10 +156,11 @@ describe('GitHubItemDialog source host boundaries', () => { it('routes PR file contents and runtime viewed invalidations through the task source context', () => { const source = componentSource('GitHubItemDialog.tsx') + const commentMutations = componentSource('github/github-work-item-comment-mutations.ts') const fileContentsSection = sourceBetween( source, 'function loadPRFileContents', - 'function setPRFileViewedForRepo' + 'function PRFilesCombinedDiffViewer' ) const fileContentsCacheKeySection = sourceBetween( source, @@ -182,9 +179,9 @@ describe('GitHubItemDialog source host boundaries', () => { expect(fileContentsSection).toContain('sourceContext: args.sourceContext') expect(fileContentsSection).toContain('sourceContext,') expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(source).toContain('options.local !== false') - expect(source).toContain('notifyWorkItemMutated({') + expect(commentMutations).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') + expect(commentMutations).toContain('options.local !== false') + expect(commentMutations).toContain('notifyWorkItemMutated({') }) it('routes merge actions through the repo owner host (#6957)', () => { @@ -192,7 +189,7 @@ describe('GitHubItemDialog source host boundaries', () => { const actionsSection = sourceBetween( source, 'function PRActionsPanel', - 'function CommentReactions' + 'function CommentReplyForm' ) expect(actionsSection).toContain( @@ -219,7 +216,7 @@ describe('GitHubItemDialog source host boundaries', () => { const checksSection = sourceBetween( source, 'function ChecksTab', - 'function getGitHubMutationSettings' + 'function GitHubLabelsSettingsLink' ) expect(checksSection).toContain('sourceContext?: TaskSourceContext | null') diff --git a/src/renderer/src/components/github-project/column-widths.ts b/src/renderer/src/components/github-project/column-widths.ts index 678de5de084..68106fb451b 100644 --- a/src/renderer/src/components/github-project/column-widths.ts +++ b/src/renderer/src/components/github-project/column-widths.ts @@ -70,17 +70,3 @@ export function resolveWidth( } return defaultWidthFor(field) } - -export function buildGridTemplate( - fields: GitHubProjectField[], - widths: Readonly> -): string { - // Why: emit `minmax(px, fr)` so the grid honors the - // user's relative weights AND fits within the container — pixel - // widths would let a wide TITLE blow past the parent and push the - // surrounding chrome out. The minmax floor is what stops a column - // from collapsing to zero when its weight is small. - const cols = fields.map((f) => `minmax(${MIN_COLUMN_WIDTH}px, ${resolveWidth(f, widths)}fr)`) - cols.push(`${ACTION_COLUMN_WIDTH}px`) - return cols.join(' ') -} diff --git a/src/renderer/src/components/github/CommentCodeContext.tsx b/src/renderer/src/components/github/CommentCodeContext.tsx new file mode 100644 index 00000000000..8dd390f9586 --- /dev/null +++ b/src/renderer/src/components/github/CommentCodeContext.tsx @@ -0,0 +1,366 @@ +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { ArrowDown, ArrowUp, Braces, LoaderCircle, UndoDot } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { detectLanguage } from '@/lib/language-detect' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + createCommentCodeContextExpansionState, + resolveCommentCodeContextExpansionState, + updateCommentCodeContextExpansionState, + type CommentCodeContextLineUpdate +} from '@/components/comment-code-context-state' +import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' +import { getPRFileContentsRenderLimit } from '@/components/github/pr-file-diff-mapping' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents, + PRComment +} from '../../../../shared/types' + +const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) + +const CODE_CONTEXT_EXPAND_STEP = 5 +const CODE_CONTEXT_FALLBACK_LINES = 20 +const CODE_CONTEXT_MAX_BLOCK_LINES = CODE_CONTEXT_FALLBACK_LINES * 2 + 1 + +/** Why: each host owns a private PR file-contents cache, so the loader is injected instead of shared. */ +export type LoadPRFileContents = (args: { + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + file: GitHubPRFile + headSha: string + baseSha: string +}) => Promise + +export function CommentCodeContext({ + comment, + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + files, + headSha, + baseSha, + loadPRFileContents +}: { + comment: PRComment + repoPath: string | null + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + files: GitHubPRFile[] + headSha: string | undefined + baseSha: string | undefined + loadPRFileContents: LoadPRFileContents +}): React.JSX.Element | null { + const [contents, setContents] = useState(null) + const [error, setError] = useState(false) + const [contextExpansionState, setContextExpansionState] = useState(() => + createCommentCodeContextExpansionState(comment.id) + ) + const file = useMemo( + () => files.find((candidate) => candidate.path === comment.path), + [comment.path, files] + ) + const line = comment.line + const startLine = comment.startLine ?? line + + useEffect(() => { + setContents(null) + setError(false) + if (!repoPath || !file || !headSha || !baseSha || !line || file.isBinary) { + return + } + let cancelled = false + loadPRFileContents({ + repoPath, + repoId, + sourceContext, + prNumber, + prRepo, + file, + headSha, + baseSha + }) + .then((result) => { + if (!cancelled) { + setContents(result) + } + }) + .catch(() => { + if (!cancelled) { + setError(true) + } + }) + return () => { + cancelled = true + } + }, [ + baseSha, + file, + headSha, + line, + loadPRFileContents, + prNumber, + prRepo, + repoId, + repoPath, + sourceContext + ]) + + const resolvedContextExpansionState = resolveCommentCodeContextExpansionState( + contextExpansionState, + comment.id + ) + if (resolvedContextExpansionState !== contextExpansionState) { + // Why: comment rows can be reused on PR refresh; reset before paint so the previous comment's expanded context isn't shown on the next. + setContextExpansionState(resolvedContextExpansionState) + } + const contextBefore = resolvedContextExpansionState.contextBefore + const contextAfter = resolvedContextExpansionState.contextAfter + const setContextBefore = useCallback( + (contextBeforeUpdate: CommentCodeContextLineUpdate) => { + setContextExpansionState((current) => + updateCommentCodeContextExpansionState(current, comment.id, { + contextBefore: contextBeforeUpdate + }) + ) + }, + [comment.id] + ) + const setContextAfter = useCallback( + (contextAfterUpdate: CommentCodeContextLineUpdate) => { + setContextExpansionState((current) => + updateCommentCodeContextExpansionState(current, comment.id, { + contextAfter: contextAfterUpdate + }) + ) + }, + [comment.id] + ) + + if (!comment.path || !line || !file || file.isBinary || error) { + return null + } + + if (!contents) { + return ( +
+ + {translate('auto.components.GitHubItemDialog.db61d76cd5', 'Loading code context…')} +
+ ) + } + + if (getPRFileContentsRenderLimit(contents).limited) { + return null + } + + const source = contents.modified || contents.original + const codeContext = getPrCommentCodeContext({ + source, + line, + startLine, + contextBefore, + contextAfter, + fallbackLines: CODE_CONTEXT_FALLBACK_LINES, + maxBlockLines: CODE_CONTEXT_MAX_BLOCK_LINES + }) + if (!codeContext) { + return null + } + const { + selectedLines, + totalLines, + commentFrom, + commentTo, + from, + to, + blockRange, + shouldUseBlockRange, + canExpandAbove, + canExpandBelow, + canExpandBlock + } = codeContext + const language = detectLanguage(comment.path) + const blockTooltip = shouldUseBlockRange + ? 'Show surrounding code block' + : 'Show nearby code context' + + return ( +
+
+
+ {comment.path} + + L{from} + {to !== from + ? translate('auto.components.GitHubItemDialog.d1c0dad471', '-L{{value0}}', { + value0: to + }) + : ''} + + {(from !== commentFrom || to !== commentTo) && ( + + {translate('auto.components.GitHubItemDialog.bd7be7b1fd', 'comment L')} + {commentFrom} + {commentTo !== commentFrom + ? translate('auto.components.GitHubItemDialog.d1c0dad471', '-L{{value0}}', { + value0: commentTo + }) + : ''} + + )} +
+ + {(contextBefore > 0 || contextAfter > 0) && ( + + + + + + {translate('auto.components.GitHubItemDialog.b1574e8ac2', 'Reset code context')} + + + )} + + + + + + {translate('auto.components.GitHubItemDialog.5664681624', 'Show more lines above')} + + + + + + + + {translate('auto.components.GitHubItemDialog.06c06e58ba', 'Show more lines below')} + + + + + + + {blockTooltip} + + +
+ + {selectedLines.map((codeLine, index) => { + const lineNumber = from + index + const isCommentedLine = lineNumber >= commentFrom && lineNumber <= commentTo + return ( +
+ + {lineNumber} + + {codeLine || ' '} +
+ ) + })} + + } + > + +
+
+ ) +} diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx new file mode 100644 index 00000000000..9c362b6520d --- /dev/null +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { GitHubReaction } from '../../../../shared/types' + +const REACTION_EMOJI: Record = { + '+1': '👍', + '-1': '👎', + laugh: '😄', + confused: '😕', + heart: '❤️', + hooray: '🎉', + rocket: '🚀', + eyes: '👀' +} + +export function CommentReactions({ + reactions +}: { + reactions?: GitHubReaction[] +}): React.JSX.Element | null { + const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) + if (visibleReactions.length === 0) { + return null + } + + return ( +
+ {visibleReactions.map((reaction) => ( + + + {reaction.count} + + ))} +
+ ) +} diff --git a/src/renderer/src/components/github/PRAssigneesPanel.tsx b/src/renderer/src/components/github/PRAssigneesPanel.tsx new file mode 100644 index 00000000000..24391745120 --- /dev/null +++ b/src/renderer/src/components/github/PRAssigneesPanel.tsx @@ -0,0 +1,255 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { LoaderCircle, Pencil } from 'lucide-react' +import { toast } from 'sonner' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useImmediateMutation, useRepoAssignees } from '@/hooks/useIssueMetadata' +import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + parseOwnerRepoFromItemUrl, + type GitHubWorkItemProjectOrigin +} from '@/components/github/github-work-item-identity' +import { runIssueUpdate } from '@/components/github/github-work-item-edit-mutations' +import { ReviewerAvatar } from '@/components/github/work-item-state-presentation' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' +import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' + +export function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + sourceContext, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubWorkItemProjectOrigin | undefined + sourceContext?: TaskSourceContext | null + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => item.assignees ?? [] + ) + const [assigneesSource, setAssigneesSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + assignees: item.assignees + })) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const { isPending, run } = useImmediateMutation() + + // Why: a background refetch can change PR assignees; sync before paint so the right rail never shows a stale reviewer/assignee split. + if ( + assigneesSource.itemId !== item.id || + assigneesSource.repoId !== item.repoId || + assigneesSource.assignees !== item.assignees + ) { + setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) + setLocalAssignees(item.assignees ?? []) + } + + const patchProjectRowIfNeeded = useCallback( + (assignees: string[]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) + }, + [patchProjectRowContent, projectOrigin] + ) + const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) + const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null + const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assigneeLogins, + sourceSettings, + projectOrigin?.host ?? assigneeSlug?.host + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath + const canEditAssignees = Boolean(projectOrigin || repoPath) + const assigneesByLogin = useMemo( + () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), + [repoAssignees.data] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + const lowerLogin = login.toLowerCase() + const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) + const prevAssignees = localAssignees + const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } + const nextAssignees = isAssigned + ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) + : [...prevAssignees, candidate] + const nextLogins = nextAssignees.map((user) => user.login) + const prevLogins = prevAssignees.map((user) => user.login) + + run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(prevLogins) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + }, + [ + assigneesByLogin, + item.id, + item.number, + item.repoId, + localAssignees, + onMutated, + patchProjectRowIfNeeded, + patchWorkItem, + projectOrigin, + repoPath, + run, + sourceContext + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/github/PRViewedCheckbox.tsx b/src/renderer/src/components/github/PRViewedCheckbox.tsx new file mode 100644 index 00000000000..1d0559138df --- /dev/null +++ b/src/renderer/src/components/github/PRViewedCheckbox.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Check, LoaderCircle } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +export function PRViewedCheckbox({ + checked, + pending, + filePath, + onToggle +}: { + checked: boolean + pending: boolean + filePath: string + onToggle: () => void +}): React.JSX.Element { + return ( + + + + + + {checked + ? translate('auto.components.GitHubItemDialog.ba8e329d92', 'Unmark viewed') + : translate('auto.components.GitHubItemDialog.16c1abe76c', 'Mark viewed')} + + + ) +} diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts deleted file mode 100644 index ab6c3ad4bfc..00000000000 --- a/src/renderer/src/components/github/github-issue-comment-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GitHubIssueCloseReason, GlobalSettings } from '../../../../shared/types' -import type { TaskSourceContext } from '../../../../shared/task-source-context' - -export type GitHubIssueCommentProjectOrigin = { - owner: string - repo: string - cacheKey: string - projectItemId: string -} - -export async function runIssueStateUpdate(args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - sourceSettings?: Pick | null | undefined - projectOrigin: GitHubIssueCommentProjectOrigin | undefined - number: number - updates: { - state: 'open' | 'closed' - stateReason?: GitHubIssueCloseReason - duplicateOf?: number - } -}): Promise { - if (args.projectOrigin) { - const target = getActiveRuntimeTarget(args.sourceSettings ?? useAppStore.getState().settings) - const updateArgs = { - owner: args.projectOrigin.owner, - repo: args.projectOrigin.repo, - number: args.number, - updates: args.updates - } - const res = - target.kind === 'environment' - ? await callRuntimeRpc>>( - target, - 'github.project.updateIssueBySlug', - updateArgs, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updateIssueBySlug(updateArgs) - if (!res.ok) { - throw new Error(res.error.message) - } - return - } - const res = await window.api.gh.updateIssue({ - repoPath: args.repoPath, - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - number: args.number, - updates: args.updates - }) - if (!res.ok) { - throw new Error(res.error) - } -} - -export async function addIssueCommentForRepo(args: { - repoId?: string - repoPath: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' -}): Promise>> { - return window.api.gh.addIssueComment({ - repoPath: args.repoPath, - repoId: args.repoId, - sourceContext: args.sourceContext, - number: args.number, - body: args.body, - type: args.type - }) -} - -export function githubAvatarUrl(login: string): string { - return `https://github.com/${encodeURIComponent(login)}.png?size=64` -} diff --git a/src/renderer/src/components/github/github-user-avatar.tsx b/src/renderer/src/components/github/github-user-avatar.tsx index 385c7feda93..9bf71b0c852 100644 --- a/src/renderer/src/components/github/github-user-avatar.tsx +++ b/src/renderer/src/components/github/github-user-avatar.tsx @@ -1,6 +1,9 @@ import { useState } from 'react' import { cn } from '@/lib/utils' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' + +export function githubAvatarUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=64` +} /** * Build a 1-2 character initials placeholder from a display name or login, diff --git a/src/renderer/src/components/github/github-work-item-comment-mutations.ts b/src/renderer/src/components/github/github-work-item-comment-mutations.ts new file mode 100644 index 00000000000..a42804365bf --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-comment-mutations.ts @@ -0,0 +1,246 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { emitGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' +import { + getGitHubRuntimeRepoId, + getGitHubSourceRuntimeHost +} from '@/lib/github-source-runtime-context' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { GitHubOwnerRepo } from '../../../../shared/types' + +export function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null +}): Promise>> { + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + if (runtimeHost) { + return callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.addIssueComment', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + number: args.number, + body: args.body, + prRepo: args.prRepo ?? null + }, + { timeoutMs: 30_000 } + ).then((result) => { + if (result.ok) { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + type: args.type ?? 'issue', + number: args.number + }, + { local: false } + ) + } + return result + }) + } + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + number: args.number, + body: args.body, + type: args.type, + prRepo: args.prRepo ?? null + }) +} + +export function addPRReviewCommentForRepo(args: { + repoId?: string + repoPath: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string +}): Promise>> { + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + if (runtimeHost) { + return callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.addPRReviewComment', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }, + { timeoutMs: 30_000 } + ).then((result) => { + if (result.ok) { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + type: 'pr', + number: args.prNumber + }, + { local: false } + ) + } + return result + }) + } + return window.api.gh.addPRReviewComment({ + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + commitId: args.commitId, + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body + }) +} + +export function addPRReviewCommentReplyForRepo(args: { + repoId?: string + repoPath: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + commentId: number + body: string + threadId?: string + path?: string + line?: number +}): Promise>> { + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + if (runtimeHost) { + return callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.addPRReviewCommentReply', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }, + { timeoutMs: 30_000 } + ).then((result) => { + if (result.ok) { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + type: 'pr', + number: args.prNumber + }, + { local: false } + ) + } + return result + }) + } + return window.api.gh.addPRReviewCommentReply({ + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + commentId: args.commentId, + body: args.body, + threadId: args.threadId, + path: args.path, + line: args.line + }) +} + +export function notifyWorkItemDetailsMutation( + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + type: 'issue' | 'pr' + number: number + }, + options: { local?: boolean } = {} +): void { + if (options.local !== false) { + emitGitHubWorkItemDetailsCacheMutation(args) + } + void window.api.gh + .notifyWorkItemMutated({ + repoPath: args.repoPath, + repoId: args.repoId, + type: args.type, + number: args.number + }) + .catch(() => undefined) +} + +export function setPRFileViewedForRepo(args: { + repoId: string + repoPath: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean +}): Promise { + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + if (runtimeHost) { + return callRuntimeRpc( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.setPRFileViewed', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId), + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId, + path: args.path, + viewed: args.viewed + }, + { timeoutMs: 30_000 } + ).then((ok) => { + if (ok) { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + type: 'pr', + number: args.prNumber + }, + { local: false } + ) + } + return ok + }) + } + return window.api.gh.setPRFileViewed({ + repoPath: args.repoPath, + repoId: args.repoId, + sourceContext: args.sourceContext, + prNumber: args.prNumber, + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId, + path: args.path, + viewed: args.viewed + }) +} diff --git a/src/renderer/src/components/github/github-work-item-edit-mutations.ts b/src/renderer/src/components/github/github-work-item-edit-mutations.ts new file mode 100644 index 00000000000..9385ef14ff2 --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-edit-mutations.ts @@ -0,0 +1,277 @@ +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + getGitHubMutationRoutingSettings, + getGitHubRuntimeRepoId, + getGitHubSourceRuntimeHost +} from '@/lib/github-source-runtime-context' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import type { GitHubWorkItemProjectOrigin } from '@/components/github/github-work-item-identity' +import { notifyWorkItemDetailsMutation } from '@/components/github/github-work-item-comment-mutations' +import { githubProjectHost } from '../../../../shared/github-project-identity' +import { + getTaskSourceRuntimeSettings, + type TaskSourceContext +} from '../../../../shared/task-source-context' +import type { GitHubOwnerRepo, GitHubWorkItem } from '../../../../shared/types' + +// Why: for a Project row whose repo differs from the active workspace, mutations must target the row's actual repo via slug-addressed IPCs, else edits silently apply to the workspace's repo. +// Why: these edit IPCs return `{ ok, error }`; callers throw on `!ok` so useImmediateMutation (which expects throws on failure) works unchanged. +export function getGitHubMutationSettings(repoId: string | null | undefined) { + const state = useAppStore.getState() + // Why: even slug-addressed project-origin mutations must run on the backing repo's owner host when its id is known. + return getSettingsForRepoRuntimeOwner(state, repoId ?? null) +} + +export async function runIssueUpdate(args: { + repoPath: string | null + repoId?: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubWorkItemProjectOrigin | undefined + number: number + updates: Parameters[0]['updates'] +}): Promise { + if (args.projectOrigin) { + const targetSettings = + args.sourceContext?.provider === 'github' + ? getTaskSourceRuntimeSettings(args.sourceContext) + : getGitHubMutationSettings(args.repoId) + const target = getActiveRuntimeTarget(targetSettings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + host: githubProjectHost(args.projectOrigin.host), + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { + timeoutMs: 30_000 + } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'issue', + number: args.number + }, + { local: false } + ) + } + return + } + const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) + if (!args.repoPath && !runtimeHost) { + throw new Error('No repo context available for this edit.') + } + const res = runtimeHost + ? await callRuntimeRpc>>( + { kind: 'environment', environmentId: runtimeHost.environmentId }, + 'github.updateIssue', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + number: args.number, + updates: args.updates + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssue({ + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } + if (runtimeHost) { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'issue', + number: args.number + }, + { local: false } + ) + } +} + +export async function runWorkItemBodyUpdate(args: { + item: GitHubWorkItem + repoPath: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubWorkItemProjectOrigin | undefined + body: string + parsedSlug: GitHubOwnerRepo | null +}): Promise { + if (args.item.type === 'pr') { + const targetSlug = args.projectOrigin + ? { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + host: args.projectOrigin.host + } + : args.parsedSlug + if (!targetSlug) { + throw new Error('No GitHub repository context available for this pull request.') + } + const targetSettings = + args.sourceContext?.provider === 'github' + ? getTaskSourceRuntimeSettings(args.sourceContext) + : getGitHubMutationSettings(args.item.repoId) + const target = getActiveRuntimeTarget(targetSettings) + const updateArgs = { + owner: targetSlug.owner, + repo: targetSlug.repo, + host: githubProjectHost(targetSlug.host), + number: args.item.number, + updates: { body: args.body } + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updatePullRequestBySlug', + updateArgs, + { + timeoutMs: 30_000 + } + ) + : await window.api.gh.updatePullRequestBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.item.repoId, + sourceContext: args.sourceContext, + type: 'pr', + number: args.item.number + }, + { local: false } + ) + } + return + } + + await runIssueUpdate({ + repoPath: args.repoPath, + repoId: args.item.repoId, + sourceContext: args.sourceContext, + projectOrigin: args.projectOrigin, + number: args.item.number, + updates: { body: args.body } + }) +} + +export async function runPullRequestStateUpdate(args: { + repoPath: string | null + repoId?: string | null + sourceContext?: TaskSourceContext | null + projectOrigin: GitHubWorkItemProjectOrigin | undefined + number: number + prRepo?: GitHubOwnerRepo | null + updates: { state: 'open' | 'closed' } +}): Promise { + if (args.projectOrigin) { + const targetSettings = + args.sourceContext?.provider === 'github' + ? getTaskSourceRuntimeSettings(args.sourceContext) + : getGitHubMutationSettings(args.repoId) + const target = getActiveRuntimeTarget(targetSettings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + host: githubProjectHost(args.projectOrigin.host), + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updatePullRequestBySlug', + updateArgs, + { + timeoutMs: 30_000 + } + ) + : await window.api.gh.updatePullRequestBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'pr', + number: args.number + }, + { local: false } + ) + } + return + } + // Why: close/reopen must route by the repo owner host like merge (#6957). + const target = getActiveRuntimeTarget( + getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) + ) + if (!args.repoPath && target.kind !== 'environment') { + throw new Error('No repo context available for this pull request.') + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.updatePRState', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + prNumber: args.number, + prRepo: args.prRepo ?? null, + updates: args.updates + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRState({ + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + prNumber: args.number, + prRepo: args.prRepo ?? null, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } + if (target.kind === 'environment') { + notifyWorkItemDetailsMutation( + { + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, + type: 'pr', + number: args.number + }, + { local: false } + ) + } +} diff --git a/src/renderer/src/components/github/github-work-item-identity.ts b/src/renderer/src/components/github/github-work-item-identity.ts new file mode 100644 index 00000000000..b4feceed9fb --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-identity.ts @@ -0,0 +1,63 @@ +import { githubProjectHost } from '../../../../shared/github-project-identity' +import type { GitHubOwnerRepo, GitHubWorkItem } from '../../../../shared/types' + +// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. +export function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { + try { + const parsed = new URL(url) + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + return { owner: segments[0], repo: segments[1], host: parsed.host } + } catch { + return null + } +} + +/** Why: a Project row may not belong to the active repo; when set, mutations route through slug-addressed IPCs against `owner`/`repo` so edits don't land on the workspace's repo. See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. */ +export type GitHubWorkItemProjectOrigin = { + owner: string + repo: string + /** GitHub host (e.g. GHES); absent means github.com. */ + host?: string + number: number + type: 'issue' | 'pr' + projectId: string + projectItemId: string + cacheKey: string +} + +// Why: every PR mutation needs the same host-pinned identity so process GH_HOST +// cannot redirect a github.com item or a Project row to the wrong server. +export function resolvePullRequestRepo( + item: Pick, + projectOrigin?: Pick +): GitHubOwnerRepo | null { + const repo = + item.prRepo ?? + (projectOrigin + ? { + owner: projectOrigin.owner, + repo: projectOrigin.repo, + host: projectOrigin.host + } + : null) ?? + parseOwnerRepoFromItemUrl(item.url) + return repo ? { ...repo, host: githubProjectHost(repo.host) } : null +} + +export type ItemDialogTab = 'conversation' | 'checks' | 'files' + +export function normalizeItemDialogTab( + item: GitHubWorkItem | null, + tab: ItemDialogTab | undefined +): ItemDialogTab { + if (item?.type !== 'pr') { + return 'conversation' + } + return tab ?? 'conversation' +} diff --git a/src/renderer/src/components/github/pr-check-presentation.ts b/src/renderer/src/components/github/pr-check-presentation.ts new file mode 100644 index 00000000000..8b500171866 --- /dev/null +++ b/src/renderer/src/components/github/pr-check-presentation.ts @@ -0,0 +1,54 @@ +import { getCheckConclusion } from '@/components/pr-check-counts' +import type { PRCheckDetail } from '../../../../shared/types' + +export function getCheckStatusLabel(check: PRCheckDetail): string { + const conclusion = getCheckConclusion(check) + if (conclusion === 'success') { + return 'Successful' + } + if (conclusion === 'failure') { + return 'Failed' + } + if (conclusion === 'cancelled') { + return 'Cancelled' + } + if (conclusion === 'timed_out') { + return 'Timed out' + } + if (conclusion === 'action_required') { + return 'Action required' + } + if (conclusion === 'neutral') { + return 'Neutral' + } + if (conclusion === 'skipped') { + return 'Skipped' + } + if (check.status === 'queued') { + return 'Queued' + } + if (check.status === 'in_progress') { + return 'In progress' + } + return 'Pending' +} + +export function getCheckDetailsKey(check: PRCheckDetail): string { + return String(check.checkRunId ?? check.workflowRunId ?? check.url ?? check.name) +} + +export function formatCheckTimestamp(input: string | null | undefined): string | null { + if (!input) { + return null + } + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return null + } + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} diff --git a/src/renderer/src/components/github/pr-file-content-size.ts b/src/renderer/src/components/github/pr-file-content-size.ts new file mode 100644 index 00000000000..89e0f26903c --- /dev/null +++ b/src/renderer/src/components/github/pr-file-content-size.ts @@ -0,0 +1,50 @@ +import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '@/components/editor/large-diff-render-limit' +import type { GitHubPRFile, GitHubPRFileContents } from '../../../../shared/types' + +export const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 + +export function isPRFileViewed(file: GitHubPRFile): boolean { + return file.viewerViewedState === 'VIEWED' +} + +export function getUtf8ByteCount(value: string): number { + let byteCount = 0 + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code < 0x80) { + byteCount += 1 + } else if (code < 0x800) { + byteCount += 2 + } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const next = value.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + byteCount += 4 + index += 1 + } else { + byteCount += 3 + } + } else { + byteCount += 3 + } + } + return byteCount +} + +export function isPRFileContentsTooLargeSentinel(contents: GitHubPRFileContents): boolean { + return contents.originalTooLarge === true || contents.modifiedTooLarge === true +} + +export function getPRFileContentsCacheByteCount(contents: GitHubPRFileContents): number { + if (isPRFileContentsTooLargeSentinel(contents)) { + return 0 + } + return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) +} + +export function getRetainedPRFileContentsByteCount(contents: GitHubPRFileContents): number | null { + if (isPRFileContentsTooLargeSentinel(contents)) { + return 0 + } + const byteCount = getPRFileContentsCacheByteCount(contents) + return byteCount <= PR_FILE_CONTENT_CACHE_MAX_BYTES ? byteCount : null +} diff --git a/src/renderer/src/components/github/pr-file-diff-mapping.ts b/src/renderer/src/components/github/pr-file-diff-mapping.ts new file mode 100644 index 00000000000..d592a0d8140 --- /dev/null +++ b/src/renderer/src/components/github/pr-file-diff-mapping.ts @@ -0,0 +1,120 @@ +import { + MAX_RENDERED_DIFF_COMBINED_CHARACTERS, + MAX_RENDERED_DIFF_LINES_PER_SIDE, + getLargeDiffRenderLimit, + type LargeDiffRenderLimit +} from '@/components/editor/large-diff-render-limit' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { + GitBranchChangeEntry, + GitDiffResult, + GitHubOwnerRepo, + GitHubPRFile, + GitHubPRFileContents, + PRComment +} from '../../../../shared/types' + +export const PR_DIFF_OVERSCAN = 5 + +// Why: overflow is a sentinel; force reported size past the render budget so downstream checks reliably pick fallback mode. +const GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT = MAX_RENDERED_DIFF_COMBINED_CHARACTERS + 1 + +export function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['status'] { + switch (status) { + case 'added': + return 'added' + case 'removed': + return 'deleted' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + case 'changed': + case 'modified': + case 'unchanged': + return 'modified' + } +} + +export function getPRFileSectionKey(path: string): string { + return `combined-commit:${path}` +} + +export function gitHubPRFileToBranchEntry(file: GitHubPRFile): GitBranchChangeEntry { + return { + path: file.path, + oldPath: file.oldPath, + status: mapPRFileStatus(file.status), + added: file.additions, + removed: file.deletions + } +} + +export function getPRFileContentsRenderLimit(contents: GitHubPRFileContents): LargeDiffRenderLimit { + if (!contents.originalTooLarge && !contents.modifiedTooLarge) { + return getLargeDiffRenderLimit({ + originalContent: contents.original, + modifiedContent: contents.modified + }) + } + + return { + limited: true, + reason: 'character-count' as const, + lineCounts: null, + characterCount: + contents.original.length + + contents.modified.length + + (contents.originalTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0) + + (contents.modifiedTooLarge ? GITHUB_PR_RAW_CONTENT_OVERFLOW_CHARACTER_COUNT : 0), + limits: { + maxLinesPerSide: MAX_RENDERED_DIFF_LINES_PER_SIDE, + maxCombinedCharacters: MAX_RENDERED_DIFF_COMBINED_CHARACTERS + } + } +} + +export function getPRFileDiffResult(contents: GitHubPRFileContents): GitDiffResult { + if (contents.originalIsBinary) { + return { + kind: 'binary', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: true, + modifiedIsBinary: contents.modifiedIsBinary + } + } + if (contents.modifiedIsBinary) { + return { + kind: 'binary', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: false, + modifiedIsBinary: true + } + } + + return { + kind: 'text', + originalContent: contents.original, + modifiedContent: contents.modified, + originalIsBinary: false, + modifiedIsBinary: false + } +} + +export type PRFilesCombinedDiffViewerProps = { + files: GitHubPRFile[] + comments: PRComment[] + repoPath: string + repoId: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + prUrl: string + headSha: string | undefined + baseSha: string | undefined + pendingViewedPaths: ReadonlySet + onCommentAdded: (comment: PRComment) => void + onViewedChange: (path: string, viewed: boolean) => Promise +} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts index 7b831270910..3cb526e6cea 100644 --- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -13,8 +13,7 @@ import { describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { githubAvatarUrl } from './github-issue-comment-helpers' -import { resolveGitHubUserAvatarSrc } from './github-user-avatar' +import { githubAvatarUrl, resolveGitHubUserAvatarSrc } from './github-user-avatar' describe('issue #8784 GHE avatar fallback (regression)', () => { it('prefers API avatar_url over login.png (GHE healthy path)', () => { diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx new file mode 100644 index 00000000000..7e14804fbaf --- /dev/null +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' +import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' + +export function formatRelativeTime(input: string): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return 'recently' + } + const diffMs = date.getTime() - Date.now() + const diffMinutes = Math.round(diffMs / 60_000) + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour') + } + const diffDays = Math.round(diffHours / 24) + return formatter.format(diffDays, 'day') +} + +export function getStateLabel(item: GitHubWorkItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'draft') { + return 'Draft' + } + if (item.state === 'closed') { + return 'Closed' + } + return 'Open' + } + return item.state === 'closed' ? 'Closed' : 'Open' +} +export function ReviewerAvatar({ + login, + avatarUrl +}: { + login: string + avatarUrl: string +}): React.JSX.Element { + return +} + +export function mergeReviewerSuggestions( + users: GitHubAssignableUser[], + seedUsers: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...seedUsers, ...users]) { + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (!existing) { + byLogin.set(key, user) + continue + } + if (!existing.avatarUrl && user.avatarUrl) { + byLogin.set(key, { ...existing, avatarUrl: user.avatarUrl }) + } + } + return Array.from(byLogin.values()).sort((a, b) => a.login.localeCompare(b.login)) +} + +export function buildRequestedReviewUsers( + logins: string[], + candidates: GitHubAssignableUser[], + existingRequests: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of existingRequests) { + byLogin.set(user.login.toLowerCase(), user) + } + const candidatesByLogin = new Map(candidates.map((user) => [user.login.toLowerCase(), user])) + for (const login of logins) { + const key = login.toLowerCase() + if (byLogin.has(key)) { + continue + } + byLogin.set(key, candidatesByLogin.get(key) ?? { login, name: null, avatarUrl: '' }) + } + return Array.from(byLogin.values()) +} diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index b9a9a518e79..b4e88121bb8 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -25,21 +25,3 @@ export function formatLinearIssueRelativeTime(input: string): string { export function buildLinearIssueBranchName(issue: LinearIssue): string { return getUsableLinearBranchName(issue.branchName) ?? getLinearIssueWorkspaceName(issue) } - -export function buildLinearIssuePrompt(issue: LinearIssue): string { - const lines = [ - `Linear issue: ${issue.identifier} ${issue.title}`, - `URL: ${issue.url}`, - `State: ${issue.state.name}`, - `Assignee: ${issue.assignee?.displayName ?? 'Unassigned'}`, - `Team: ${issue.team.name}` - ] - if (issue.workspaceName) { - lines.push(`Workspace: ${issue.workspaceName}`) - } - const description = issue.description?.trim() - if (description) { - lines.push('', 'Description:', description) - } - return lines.join('\n') -} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index c7c320af52e..a14bed089f6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -24,8 +24,6 @@ export { type NativeChatSkillDiscoverySnapshot } from './native-chat-picker-items' -export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill' - type PickerAutocomplete = { query: string items: NativeChatPickerItem[] diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index e02cb931855..b21b4c742e8 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -40,8 +40,6 @@ import { buildWindowsTerminalSnapshotPayload } from './windows-terminal-onboardi export { STEPS } from './use-onboarding-flow-types' export type { StepId, StepNumber } from './use-onboarding-flow-types' -export type OnboardingFlowController = ReturnType - type TaskSourcesSnapshotProps = EventProps<'onboarding_task_sources_snapshot'> type TaskSourcesGithubStatus = TaskSourcesSnapshotProps['github_status'] type TaskSourcesLinearStatus = TaskSourcesSnapshotProps['linear_status'] diff --git a/src/renderer/src/components/pull-request-page-host-boundary.test.ts b/src/renderer/src/components/pull-request-page-host-boundary.test.ts index 507f20d2c87..10407760e6d 100644 --- a/src/renderer/src/components/pull-request-page-host-boundary.test.ts +++ b/src/renderer/src/components/pull-request-page-host-boundary.test.ts @@ -19,7 +19,11 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('PullRequestPage host boundaries', () => { it('routes reviewer metadata and mutations through the PR repo owner host', () => { const source = componentSource('PullRequestPage.tsx') - const section = sourceBetween(source, 'function PRReviewersPanel', 'function isPRFileViewed') + const section = sourceBetween( + source, + 'function PRReviewersPanel', + 'const WORK_ITEM_DETAILS_CACHE_MAX' + ) expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)') expect(section).toContain('useRepoAssigneesBySlug(') @@ -58,15 +62,11 @@ describe('PullRequestPage host boundaries', () => { it('source-scopes full-page optimistic work item patches', () => { const source = componentSource('PullRequestPage.tsx') - const prAssigneesSection = sourceBetween( - source, - 'function PRAssigneesPanel', - 'function PRReviewersPanel' - ) + const prAssigneesSection = componentSource('github/PRAssigneesPanel.tsx') const prActionsSection = sourceBetween( source, 'function PRActionsPanel', - 'function CommentReactions' + 'function CommentReplyForm' ) const issueEditSection = sourceBetween( source, @@ -101,7 +101,7 @@ describe('PullRequestPage host boundaries', () => { const propsSection = sourceBetween( source, 'type PullRequestPageProps', - 'function formatRelativeTime' + 'function findMentionQuery' ) const cacheKeySection = sourceBetween( source, @@ -142,11 +142,7 @@ describe('PullRequestPage host boundaries', () => { it('routes file viewed mutations through the PR source context', () => { const source = componentSource('PullRequestPage.tsx') - const helperSection = sourceBetween( - source, - 'function setPRFileViewedForRepo', - 'function PRViewedCheckbox' - ) + const helperSection = componentSource('github/github-work-item-comment-mutations.ts') const changeSection = sourceBetween( source, 'const handlePRFileViewedChange = useCallback', @@ -166,7 +162,7 @@ describe('PullRequestPage host boundaries', () => { it('routes comment mutations through runtime source context when needed', () => { const source = componentSource('PullRequestPage.tsx') const helperSection = sourceBetween( - source, + componentSource('github/github-work-item-comment-mutations.ts'), 'function addIssueCommentForRepo', 'function setPRFileViewedForRepo' ) @@ -201,10 +197,11 @@ describe('PullRequestPage host boundaries', () => { it('routes PR file contents and runtime viewed invalidations through the PR source context', () => { const source = componentSource('PullRequestPage.tsx') + const commentMutations = componentSource('github/github-work-item-comment-mutations.ts') const fileContentsSection = sourceBetween( source, 'function loadPRFileContents', - 'function setPRFileViewedForRepo' + 'type CachedPRFilesDiffViewState' ) const fileContentsCacheKeySection = sourceBetween( source, @@ -213,7 +210,7 @@ describe('PullRequestPage host boundaries', () => { ) const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') const commentContextSection = sourceBetween( - source, + componentSource('github/CommentCodeContext.tsx'), 'function CommentCodeContext', 'const resolvedContextExpansionState' ) @@ -228,9 +225,9 @@ describe('PullRequestPage host boundaries', () => { expect(fileContentsSection).toContain('sourceContext: args.sourceContext') expect(fileContentsSection).toContain('sourceContext,') expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') - expect(source).toContain('options.local !== false') - expect(source).toContain('notifyWorkItemMutated({') + expect(commentMutations).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') + expect(commentMutations).toContain('options.local !== false') + expect(commentMutations).toContain('notifyWorkItemMutated({') expect(commentContextSection).toContain('sourceContext?: TaskSourceContext | null') expect(commentContextSection).toMatch( /loadPRFileContents\(\{\s*repoPath,\s*repoId,\s*sourceContext,\s*prNumber,\s*prRepo,/ @@ -256,11 +253,7 @@ describe('PullRequestPage host boundaries', () => { it('routes edit metadata and mutations through the PR source context', () => { const source = componentSource('PullRequestPage.tsx') - const editHelperSection = sourceBetween( - source, - 'function getGitHubMutationSettings', - 'function GHCommentComposer' - ) + const editHelperSection = componentSource('github/github-work-item-edit-mutations.ts') const editSection = sourceBetween( source, 'function GHEditSection', @@ -292,7 +285,7 @@ describe('PullRequestPage host boundaries', () => { const actionsSection = sourceBetween( source, 'function PRActionsPanel', - 'function CommentReactions' + 'function CommentReplyForm' ) expect(actionsSection).toContain( diff --git a/src/renderer/src/components/repo/repo-icon.tsx b/src/renderer/src/components/repo/repo-icon.tsx index 00d897383df..b82de9e1173 100644 --- a/src/renderer/src/components/repo/repo-icon.tsx +++ b/src/renderer/src/components/repo/repo-icon.tsx @@ -27,12 +27,6 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { createLocalizedCatalog } from '@/i18n/localized-catalog' -export type RepoLucideIconOption = { - name: string - label: string - icon: LucideIcon -} - export const getRepoLucideIconOptions = createLocalizedCatalog(() => [ { name: 'Folder', diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 048ba89252e..0113f0d22e9 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -81,6 +81,7 @@ export default function AiVaultPanel(): React.JSX.Element { setGroup, setHideEmptySessions, setAgentEnabled, + setAllAgentsEnabled, resetViewOptions } = usePersistedAiVaultViewOptions() const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) @@ -320,6 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element { onScopeChange={handleScopeChange} onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} + onAllAgentsEnabledChange={setAllAgentsEnabled} onSortChange={setSort} onGroupChange={setGroup} onHideEmptySessionsChange={setHideEmptySessions} @@ -349,6 +351,7 @@ export default function AiVaultPanel(): React.JSX.Element { loading={loading} sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} + noAgentsSelected={agents.length === 0} error={error} vaultScope={scope} buildResumeStartup={launchActions.buildResumeStartup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index b64308b943e..0a82ca00e2b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -39,6 +39,9 @@ import type { AiVaultHostScopeOption } from './ai-vault-host-scope' const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0' +const AGENT_BULK_ACTION_CLASS = + 'rounded-full px-2 py-0.5 text-[11px] font-normal text-muted-foreground focus:text-foreground' + // Why: match ToggleGroup's spacing+outline qualifiers so selected edges out-specify its border-l-0 collapse. const VAULT_SCOPE_SELECTED_EDGE_CLASS = 'data-[spacing=0]:data-[variant=outline]:aria-[checked=true]:border-l data-[spacing=0]:data-[variant=outline]:data-[state=on]:border-l' @@ -234,6 +237,7 @@ export function VaultViewMenu({ hideEmptySessions, adjustmentCount, onAgentEnabledChange, + onAllAgentsEnabledChange, onSortChange, onGroupChange, onHideEmptySessionsChange, @@ -245,11 +249,15 @@ export function VaultViewMenu({ hideEmptySessions: boolean adjustmentCount: number onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void + onAllAgentsEnabledChange: (enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void onReset: () => void }): React.JSX.Element { + const allAgentsSelected = agents.length === AI_VAULT_AGENTS.length + const noAgentsSelected = agents.length === 0 + return ( @@ -284,14 +292,43 @@ export function VaultViewMenu({ - - {translate('auto.components.right.sidebar.AiVaultPanelControls.agents', 'Agents')} - + {/* Why: Select all / Clear lets users isolate one agent without unchecking 15 boxes. */} +
+ + {translate('auto.components.right.sidebar.AiVaultPanelControls.agents', 'Agents')} + + {/* Why: real menu items so arrow keys reach them; plain buttons are skipped by Radix roving focus. */} +
+ { + event.preventDefault() + onAllAgentsEnabledChange(true) + }} + className={AGENT_BULK_ACTION_CLASS} + > + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.selectAllAgents', + 'Select all' + )} + + { + event.preventDefault() + onAllAgentsEnabledChange(false) + }} + className={AGENT_BULK_ACTION_CLASS} + > + {translate('auto.components.right.sidebar.AiVaultPanelControls.clearAgents', 'Clear')} + +
+
{AI_VAULT_AGENTS.map((agent) => ( onAgentEnabledChange(agent, checked === true)} onSelect={(event) => event.preventDefault()} > diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index b3ec2ba36e3..86c728aa8a4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -31,6 +31,7 @@ type AiVaultPanelHeaderProps = { onScopeChange: (scope: AiVaultScope) => void onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void + onAllAgentsEnabledChange: (enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void @@ -58,6 +59,7 @@ export function AiVaultPanelHeader({ onScopeChange, onExecutionHostScopeChange, onAgentEnabledChange, + onAllAgentsEnabledChange, onSortChange, onGroupChange, onHideEmptySessionsChange, @@ -119,6 +121,7 @@ export function AiVaultPanelHeader({ hideEmptySessions={hideEmptySessions} adjustmentCount={adjustmentCount} onAgentEnabledChange={onAgentEnabledChange} + onAllAgentsEnabledChange={onAllAgentsEnabledChange} onSortChange={onSortChange} onGroupChange={onGroupChange} onHideEmptySessionsChange={onHideEmptySessionsChange} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 29b21ff071a..30e44d375ac 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,5 +1,12 @@ import type React from 'react' -import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' +import { + FileJson, + FolderGit2, + MessageSquare, + MessageSquarePlus, + Play, + TextCursorInput +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -9,7 +16,8 @@ import { type AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' -import { sessionDetailConversationTurns } from './ai-vault-session-display' +import { FirstPromptCard } from './ai-vault-first-prompt-card' +import { sessionDetailConversationTurns, sessionFirstPrompt } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' import { @@ -51,6 +59,7 @@ export function SessionInlineDetails({ const showResumeInNewTab = hasResumableContent && (!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId)) + const firstPromptPreview = sessionFirstPrompt(session) const detailTurns = sessionDetailConversationTurns(session, 3) const worktreeDisplay = worktreeInfo @@ -66,78 +75,8 @@ export function SessionInlineDetails({ event.stopPropagation() }} > -
- {hasResumableContent ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', - 'Latest turns' - )} - > - {detailTurns.length > 0 ? ( -
- {detailTurns.map((turn, index) => ( - - ))} -
- ) : ( - - )} -
- ) : ( - // An unsaved session has no turns to show; the notice replaces the - // preview section instead of stacking a second empty state under it. - - )} - - - - {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { - vaultScope - }) ? ( - } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.worktree', - 'Worktree' - )} - > - - - ) : null} -
- {showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? ( -
- {onContinueInNewSession ? ( - - ) : null} +
{showResumeInWorktree ? ( + ) : null} {onOpenLog ? (
) } @@ -241,7 +265,7 @@ function ConversationTurnCard({
{conversationRoleLabel(role)}
-

+

{text}

@@ -313,77 +337,6 @@ function SessionDetailEmptyState({ message }: { message: string }): React.JSX.El ) } -export function SessionTime({ - value, - className -}: { - value: string - className?: string -}): React.JSX.Element { - const timestamp = Date.parse(value) - if (!Number.isFinite(timestamp)) { - return ( - - {translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime', - 'Unknown time' - )} - - ) - } - - const date = new Date(timestamp) - return ( - - - - ) -} - -function formatTimeAgo(timestamp: number): string { - const diffMs = Date.now() - timestamp - if (diffMs < 60_000) { - return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now') - } - const minutes = Math.floor(diffMs / 60_000) - if (minutes < 60) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo', - '{{value0}}m ago', - { value0: minutes } - ) - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo', - '{{value0}}h ago', - { value0: hours } - ) - } - const days = Math.floor(hours / 24) - if (days < 30) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo', - '{{value0}}d ago', - { value0: days } - ) - } - const months = Math.floor(days / 30) - if (months < 12) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo', - '{{value0}}mo ago', - { value0: months } - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo', - '{{value0}}y ago', - { value0: Math.floor(months / 12) } - ) -} - function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { if (role === 'user') { return translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 921ecf33f30..416fb2bb79e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -87,11 +87,6 @@ export function VaultSessionRow({ const startResumeDrag = useCallback( (event: React.DragEvent): void => { event.stopPropagation() - const target = event.target - if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) { - event.preventDefault() - return - } if (resumeDisabled) { event.preventDefault() return @@ -122,27 +117,35 @@ export function VaultSessionRow({