From b5a85890acff397adcea565733766190bfd75462 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:19:05 -0700 Subject: [PATCH] perf(git): bound git subprocess execution with an atomic admission scheduler (#16874) * perf(git): bound git subprocess execution with an atomic admission scheduler Field traces (#16038, #11363) show Windows freeze storms driven by unbounded concurrent git children (12+ at once, 50-65s status convoys for 25+ minutes). Admit every main-process git child against atomic per-budget base+headroom counters (general / network / per-route), with reserved interactive capacity, ordering-only aging, close-bound permit release, a 120s fail-safe read timeout that feeds scheduler backoff, tier plumbing through every option carrier, and coalesced+jittered visibility pollers. Killswitch: ORCA_GIT_ADMISSION_DISABLED=1. Storm harness A/B: max concurrent children 65 -> 6, interactive p95 791ms -> 88ms; output-parity battery byte-identical with admission on vs off. * test(git): run the admission output-parity battery on every platform Parity needs real git, not the storm harness's PATH stub, so it must not share that file's POSIX gate - Windows is the platform where parity evidence matters. * fix(git): preserve interactive admission invariants * perf(git): keep admission queue drains linear * fix(git): close final admission gaps * perf(git): bound eligible route selection * fix(merge): remove unrelated stale snapshot changes * fix(git): preserve refresh lifecycle authority * test(git): align admission lifetime contracts * fix(git): harden admission across runtime paths * fix(git): restore freshness for bulk status reads * test(git): repoint delete-dialog source pins after admission plumbing The hydration effect now orders its targets through orderDeleteWorktreeStatusHydrationTargets and passes includeLineStats alongside the abort signal, so both literal anchors stopped matching. The invariants are unchanged and still pinned: dropping the signal, the main-worktree/folder filter, or getState-instead-of-subscribe each still reddens this test. * Fix git admission tier propagation and lock ordering Decode optional Git status tiers permissively and default runtime RPC status reads to the status lane while preserving renderer caller intent. Acquire the FETCH_HEAD mutex before atomic admission so same-repository fetch waiters hold no global or route permits. Preserve automatic pull-request refresh reasons, keep explicit hosted-review refreshes interactive, remove the dead candidate tier, and keep relay scheduling unchanged. Use tier-aware status lease keys because a shared lease cannot be safely promoted after its admission request is queued or granted. * test: align expectations with admission plumbing * refactor(child-process): move the process contract types to process-spec run-process.ts crossed its line cap after gaining the termination observer; the public types and defaults move out with re-exports so no caller changes. * chore: restore pnpm-lock.yaml to main (unintended local drift) --------- Co-authored-by: Merge Sim --- src/main/git/admission-tier-plumbing.test.ts | 48 ++ .../git/command-runner/exec-file-capture.ts | 35 +- .../git-admission-candidate-heap.ts | 85 +++ .../git-admission-output-parity.test.ts | 60 ++ .../command-runner/git-admission-span.test.ts | 168 ++++++ .../git/command-runner/git-admission-state.ts | 141 +++++ .../git-admission-storm-measurement.test.ts | 250 ++++++++ .../git-admission-waiter-queue.ts | 249 ++++++++ .../git-command-timeout-behavior.test.ts | 144 +++++ .../git-command-timeout.test.ts | 39 ++ .../git/command-runner/git-command-timeout.ts | 23 + .../git-exec-admission-lifetime.test.ts | 237 ++++++++ src/main/git/command-runner/git-exec-file.ts | 189 ++++-- .../git/command-runner/git-exec-options.ts | 6 + .../git-spawn-admission-lifetime.test.ts | 79 +++ src/main/git/command-runner/git-spawn.ts | 76 ++- .../git/command-runner/git-ssh-policy-env.ts | 18 +- .../git-stream-admission-lifetime.test.ts | 79 +++ .../git/command-runner/git-stream-stdout.ts | 99 +++- .../git-subprocess-admission.test.ts | 558 ++++++++++++++++++ .../git-subprocess-admission.ts | 325 ++++++++++ src/main/git/git-runtime-options.ts | 9 +- src/main/git/push-target-validation.ts | 5 +- src/main/git/remote-ref-probe-cache.ts | 5 +- src/main/git/remote-url-probe.ts | 5 +- src/main/git/runner-command-exec.test.ts | 47 ++ .../runner-windows-host-environment.test.ts | 2 + src/main/git/runner-wsl-direct-read.test.ts | 71 ++- .../runner-wsl-linked-gitdir-timeout.test.ts | 3 + .../runner-wsl-login-shell-capture.test.ts | 3 + src/main/git/runner-wsl-read-routing.test.ts | 3 + src/main/git/runner.ts | 7 +- .../git/source-control/get-status-options.ts | 1 + src/main/git/source-control/status-read.ts | 10 +- .../wsl-tracked-pathspec-banner.test.ts | 3 + src/main/git/status-read-coalescing.test.ts | 18 +- src/main/git/status.test.ts | 29 + src/main/git/upstream.ts | 10 +- .../git/wsl-direct-git-read-commands.test.ts | 73 ++- src/main/git/wsl-direct-git-read-commands.ts | 81 ++- .../client/lookup/pull-request-lookup-data.ts | 6 +- .../client/lookup/tracked-upstream-branch.ts | 10 +- src/main/github/conflict-summary.test.ts | 20 + src/main/github/conflict-summary.ts | 25 +- src/main/github/github-repository-identity.ts | 10 +- .../github/pr-refresh-candidate-policy.ts | 16 +- ...fresh-coordinator-alias-coalescing.test.ts | 11 +- ...refresh-coordinator-refresh-events.test.ts | 24 +- src/main/github/pr-refresh-coordinator.ts | 24 +- src/main/github/pr-refresh-queue-drainer.ts | 2 +- src/main/gitlab/gitlab-known-host-probe.ts | 5 +- .../gitlab/gitlab-project-ref-resolution.ts | 9 +- src/main/gitlab/gl-utils.test.ts | 15 + .../filesystem-branch-compare-diff.test.ts | 9 +- ...lesystem-commit-message-generation.test.ts | 5 +- .../filesystem-git-commit-dispatch.test.ts | 4 +- .../ipc/filesystem-git-status-staging.test.ts | 76 ++- ...list-files-git-directory-expansion.test.ts | 8 +- .../ipc/filesystem-list-files-git-fallback.ts | 1 + src/main/ipc/filesystem-list-files.test.ts | 15 + src/main/ipc/filesystem-search-git.test.ts | 38 +- src/main/ipc/filesystem-search-git.ts | 1 + src/main/ipc/filesystem.ts | 118 +++- src/main/ipc/github-pr-refresh-handlers.ts | 8 +- src/main/ipc/hosted-review.test.ts | 37 +- src/main/ipc/hosted-review.ts | 19 +- .../ipc/repos-local-clone-lifecycle.test.ts | 1 + src/main/ipc/repos/repo-clone-lifecycle.ts | 1 + .../ipc/workspace-cleanup-git-evidence.ts | 3 +- src/main/ipc/workspace-cleanup.test.ts | 2 + src/main/observability/instrumentation.ts | 19 +- src/main/providers/git-provider-contract.ts | 7 +- .../providers/git-provider-status-options.ts | 2 + .../ssh-git-provider-status-lease.test.ts | 14 +- .../providers/ssh-git-provider-status.test.ts | 19 +- src/main/providers/ssh-git-read-provider.ts | 4 + .../ssh-git-working-tree-provider.ts | 10 +- .../providers/working-directory-validation.ts | 2 +- .../orca-runtime-git-diff-budget.test.ts | 27 +- src/main/runtime/orca-runtime-git.test.ts | 114 ++-- src/main/runtime/orca-runtime.test.ts | 51 +- src/main/runtime/orca-runtime.ts | 46 +- .../rpc/methods/git-admission-tier-schema.ts | 11 + src/main/runtime/rpc/methods/git-params.ts | 4 + src/main/runtime/rpc/methods/git.test.ts | 62 +- src/main/runtime/rpc/methods/git.ts | 10 +- .../methods/github-pr-refresh-reason.test.ts | 69 +++ .../methods/github-pull-request-methods.ts | 18 +- .../runtime/rpc/methods/hosted-review.test.ts | 20 + src/main/runtime/rpc/methods/hosted-review.ts | 3 + ...ntime-git-branch-compare-admission.test.ts | 83 +++ src/main/runtime/runtime-git-diff-commands.ts | 37 +- .../runtime-git-generation-admission.test.ts | 159 +++++ .../runtime-git-generation-commands.ts | 15 +- .../runtime/runtime-git-staging-commands.ts | 30 +- .../runtime-git-status-admission.test.ts | 57 ++ .../runtime/runtime-git-status-commands.ts | 19 +- .../runtime/runtime-git-sync-commands.test.ts | 138 +++++ src/main/runtime/runtime-git-sync-commands.ts | 47 +- ...untime-rpc-mobile-method-allowlist.test.ts | 2 +- .../hosted-review-git-options.ts | 9 +- .../pull-request-linked-issue.ts | 3 +- .../source-control/repo-default-branch.ts | 3 + src/preload/api/git-inspection-api.ts | 3 + src/preload/api/github-pull-request-api.ts | 5 +- src/preload/index.ts | 9 +- src/relay/git-handler-status-ops.test.ts | 31 + src/relay/git-handler-status-ops.ts | 7 +- .../use-checks-panel-conflict-refresh.tsx | 3 +- .../use-checks-panel-controller-state.tsx | 4 - .../use-checks-panel-foreground-effects.tsx | 32 +- ...e-checks-panel-git-status-effects.test.tsx | 246 ++++++++ .../use-checks-panel-git-status-effects.tsx | 135 +++-- .../use-checks-panel-manual-refresh.test.tsx | 8 + .../use-checks-panel-manual-refresh.tsx | 9 +- .../coalesced-poll-runner.test.ts | 29 +- ...t-status-refresh-branch-line-total.test.ts | 9 + .../git-status-refresh-scheduler.test.ts | 6 +- .../right-sidebar/git-status-refresh.test.ts | 10 +- .../right-sidebar/git-status-refresh.ts | 3 + .../review/use-hosted-review-created.ts | 3 +- .../source-control/sync/use-branch-compare.ts | 258 +++++--- .../source-control/sync/use-status-refresh.ts | 2 +- ...use-source-control-branch-compare.test.tsx | 26 +- .../right-sidebar/useGitStatusPolling.test.ts | 25 +- .../right-sidebar/useGitStatusPolling.ts | 11 +- ...rktreeDialog.host-context-boundary.test.ts | 4 +- .../sidebar/DeleteWorktreeDialog.tsx | 1 + ...elete-worktree-dirty-change-counts.test.ts | 47 ++ .../delete-worktree-dirty-change-counts.ts | 26 +- .../use-delete-worktree-status-hydration.ts | 19 +- .../use-worktree-card-lifecycle-effects.ts | 1 + .../status-bar/WorkspaceSpaceManagerPanel.tsx | 32 +- .../workspace-space-git-status-order.ts | 30 + .../workspace-space-presentation.test.ts | 23 +- .../workspace-space-presentation.ts | 8 - .../lib/window-visibility-interval.test.ts | 89 +++ .../src/lib/window-visibility-interval.ts | 46 +- .../src/runtime/runtime-git-client.test.ts | 80 +++ .../src/runtime/runtime-git-diff-client.ts | 8 +- .../src/runtime/runtime-git-status-client.ts | 9 + .../store/github/pull-request-execution.ts | 9 +- .../store/github/refresh-routing-actions.ts | 9 +- .../src/store/github/refresh-sweep-actions.ts | 6 +- src/renderer/src/store/github/slice-types.ts | 1 + .../github/stale-worktree-refresh-actions.ts | 3 +- ...hub-pr-branch-direct-refresh-scope.test.ts | 1 + .../github-pr-branch-fallback-results.test.ts | 1 + .../github-pr-refresh-owner-routing.test.ts | 89 +-- .../store/slices/github-refresh-sweep.test.ts | 16 +- .../store/slices/github-slice-test-harness.ts | 3 +- .../github-worktree-refresh-if-stale.test.ts | 14 +- .../store/slices/hosted-review-cache-state.ts | 1 + .../store/slices/hosted-review-cache.test.ts | 81 +++ .../slices/hosted-review-card-refresh.ts | 2 + .../slices/hosted-review-request-state.ts | 168 +++++- .../src/store/slices/hosted-review.test.ts | 17 + .../src/store/slices/hosted-review.ts | 27 +- .../worktrees-terminal-pr-url-linking.test.ts | 9 +- .../session/worktree-unread-activity.ts | 3 +- .../src/web/preload-api/web-git-api.ts | 7 +- .../src/web/preload-api/web-github-api.ts | 3 +- .../src/web/web-preload-api-git.test.ts | 29 + .../child-termination-reporter.ts | 16 + src/shared/child-process/process-spec.ts | 71 +++ .../run-process-termination-failure.test.ts | 52 +- src/shared/child-process/run-process.test.ts | 7 +- src/shared/child-process/run-process.ts | 84 +-- .../github/pull-request-refresh-types.ts | 4 +- src/shared/hosted-review.ts | 1 + 170 files changed, 6186 insertions(+), 775 deletions(-) create mode 100644 src/main/git/admission-tier-plumbing.test.ts create mode 100644 src/main/git/command-runner/git-admission-candidate-heap.ts create mode 100644 src/main/git/command-runner/git-admission-output-parity.test.ts create mode 100644 src/main/git/command-runner/git-admission-span.test.ts create mode 100644 src/main/git/command-runner/git-admission-state.ts create mode 100644 src/main/git/command-runner/git-admission-storm-measurement.test.ts create mode 100644 src/main/git/command-runner/git-admission-waiter-queue.ts create mode 100644 src/main/git/command-runner/git-command-timeout-behavior.test.ts create mode 100644 src/main/git/command-runner/git-command-timeout.test.ts create mode 100644 src/main/git/command-runner/git-command-timeout.ts create mode 100644 src/main/git/command-runner/git-exec-admission-lifetime.test.ts create mode 100644 src/main/git/command-runner/git-spawn-admission-lifetime.test.ts create mode 100644 src/main/git/command-runner/git-stream-admission-lifetime.test.ts create mode 100644 src/main/git/command-runner/git-subprocess-admission.test.ts create mode 100644 src/main/git/command-runner/git-subprocess-admission.ts create mode 100644 src/main/runtime/rpc/methods/git-admission-tier-schema.ts create mode 100644 src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts create mode 100644 src/main/runtime/runtime-git-branch-compare-admission.test.ts create mode 100644 src/main/runtime/runtime-git-generation-admission.test.ts create mode 100644 src/main/runtime/runtime-git-status-admission.test.ts create mode 100644 src/main/runtime/runtime-git-sync-commands.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx create mode 100644 src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts create mode 100644 src/renderer/src/components/status-bar/workspace-space-git-status-order.ts create mode 100644 src/shared/child-process/child-termination-reporter.ts create mode 100644 src/shared/child-process/process-spec.ts diff --git a/src/main/git/admission-tier-plumbing.test.ts b/src/main/git/admission-tier-plumbing.test.ts new file mode 100644 index 00000000000..aba8c4a9c35 --- /dev/null +++ b/src/main/git/admission-tier-plumbing.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { hostedReviewOptionArgs } from '../github/pr-refresh-candidate-policy' +import { getHostedReviewLocalGitOptions } from '../source-control/hosted-review-git-options' +import { gitOptionsForWorktree, gitReadOptionsForWorktree } from './git-runtime-options' + +describe('git admission tier plumbing', () => { + it('preserves tiers through both runtime option constructors', () => { + expect( + gitOptionsForWorktree('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' }) + ).toEqual({ cwd: '/repo', wslDistro: 'Ubuntu', admissionTier: 'interactive' }) + expect( + gitReadOptionsForWorktree('/repo', { wslDistro: 'Ubuntu', admissionTier: 'background' }) + ).toEqual({ + cwd: '/repo', + wslDistro: 'Ubuntu', + admissionTier: 'background', + preferWslDirectGit: true + }) + }) + + it('preserves the hosted-review execution tier beside WSL routing', () => { + expect( + getHostedReviewLocalGitOptions({ + localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } + }) + ).toEqual({ wslDistro: 'Ubuntu', admissionTier: 'interactive' }) + }) + + it.each([ + ['manual', 'interactive'], + ['visible', 'background'], + ['active', 'background'], + ['post-push', 'background'], + ['swr', 'background'] + ] as const)('maps PR refresh reason %s to %s admission', (reason, admissionTier) => { + const [options] = hostedReviewOptionArgs( + { + localGitOptions: { wslDistro: 'Ubuntu' }, + linkedPRNumber: null, + fallbackPRNumber: null, + fallbackPRSource: null, + currentHeadOid: null + }, + reason + ) + expect(options?.localGitExecOptions).toEqual({ wslDistro: 'Ubuntu', admissionTier }) + }) +}) diff --git a/src/main/git/command-runner/exec-file-capture.ts b/src/main/git/command-runner/exec-file-capture.ts index 96812284c34..b1b9c664176 100644 --- a/src/main/git/command-runner/exec-file-capture.ts +++ b/src/main/git/command-runner/exec-file-capture.ts @@ -6,11 +6,15 @@ import type { WslProcessGroupTermination } from '../wsl-process-group-terminatio import { createAbortError } from './abort-error' import { killSpawnedCommandTree } from './spawned-command-tree-kill' import { DEFAULT_GIT_MAX_BUFFER } from './git-exec-options' +import type { GitAdmissionTier } from './git-exec-options' type ExecFileCaptureOptions = Omit & { timeout?: number stdin?: string terminationBarrier?: boolean + onChildTerminated?: () => void + admissionTier?: GitAdmissionTier + createTimeoutError?: () => Error } const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000 @@ -30,6 +34,7 @@ export async function execFileCaptureToTermination( maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, signal: options.signal, terminationBarrier: termination ?? true, + onChildTerminated: options.onChildTerminated, ...(options.stdin === undefined ? {} : { input: options.stdin }) }) const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout @@ -38,13 +43,13 @@ export async function execFileCaptureToTermination( if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { return { stdout, stderr } } - const error = new Error( - result.timedOut - ? `${command} timed out.` - : options.signal?.aborted - ? 'The operation was aborted.' - : cleanStderr.trim() || `${command} exited with ${result.code}.` - ) + const error = result.timedOut + ? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`)) + : new Error( + options.signal?.aborted + ? 'The operation was aborted.' + : cleanStderr.trim() || `${command} exited with ${result.code}.` + ) if (options.signal?.aborted) { error.name = 'AbortError' } @@ -80,6 +85,7 @@ export function execFileCapture( ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { return new Promise((resolve, reject) => { if (options.signal?.aborted) { + options.onChildTerminated?.() reject(createAbortError()) return } @@ -88,6 +94,14 @@ export function execFileCapture( let terminating = false let child: ChildProcess | null = null let timer: NodeJS.Timeout | null = null + let terminationReported = false + const reportChildTerminated = (): void => { + if (terminationReported) { + return + } + terminationReported = true + options.onChildTerminated?.() + } const cleanup = (): void => { if (timer) { clearTimeout(timer) @@ -160,15 +174,20 @@ export function execFileCapture( ) recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) } catch (error) { + reportChildTerminated() finish(error instanceof Error ? error : new Error(String(error))) return } child.once('error', (error) => { + if (!child?.pid) { + reportChildTerminated() + } if (!terminating) { finish(error) } }) + child.once('close', reportChildTerminated) if (options.stdin !== undefined) { endSubprocessStdin(child.stdin, options.stdin) @@ -181,7 +200,7 @@ export function execFileCapture( return } terminating = true - const timeoutError = new Error(`${command} timed out.`) + const timeoutError = options.createTimeoutError?.() ?? new Error(`${command} timed out.`) if (!child) { terminating = false finish(timeoutError) diff --git a/src/main/git/command-runner/git-admission-candidate-heap.ts b/src/main/git/command-runner/git-admission-candidate-heap.ts new file mode 100644 index 00000000000..ca22cac6097 --- /dev/null +++ b/src/main/git/command-runner/git-admission-candidate-heap.ts @@ -0,0 +1,85 @@ +import type { AdmissionWaiter } from './git-admission-state' + +export type WaiterLane = { + items: AdmissionWaiter[] + head: number + count: number + baseEligible: boolean + headroomEligible: boolean + version: number +} + +export type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number } + +const CANDIDATE_HEAP_COMPACTION_SLACK = 64 + +export class CandidateHeap { + private items: Candidate[] = [] + + get size(): number { + return this.items.length + } + + push(candidate: Candidate): void { + this.items.push(candidate) + let index = this.items.length - 1 + while (index > 0) { + const parent = Math.floor((index - 1) / 2) + if (this.items[parent].waiter.id <= candidate.waiter.id) { + break + } + this.items[index] = this.items[parent] + index = parent + } + this.items[index] = candidate + } + + peek(valid: (candidate: Candidate) => boolean): Candidate | null { + // Eligibility changes invalidate by version so route updates stay O(log N). + while (this.items.length > 0 && !valid(this.items[0])) { + this.pop() + } + return this.items[0] ?? null + } + + compactIfOversized(maxLiveCandidates: number, valid: (candidate: Candidate) => boolean): void { + if (this.items.length <= maxLiveCandidates * 2 + CANDIDATE_HEAP_COMPACTION_SLACK) { + return + } + this.items = this.items.filter(valid) + for (let index = Math.floor(this.items.length / 2) - 1; index >= 0; index -= 1) { + this.siftDown(index) + } + } + + private pop(): void { + const last = this.items.pop() + if (!last || this.items.length === 0) { + return + } + this.items[0] = last + this.siftDown(0) + } + + private siftDown(index: number): void { + const candidate = this.items[index] + let cursor = index + while (true) { + const left = cursor * 2 + 1 + if (left >= this.items.length) { + break + } + const right = left + 1 + const child = + right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id + ? right + : left + if (this.items[child].waiter.id >= candidate.waiter.id) { + break + } + this.items[cursor] = this.items[child] + cursor = child + } + this.items[cursor] = candidate + } +} diff --git a/src/main/git/command-runner/git-admission-output-parity.test.ts b/src/main/git/command-runner/git-admission-output-parity.test.ts new file mode 100644 index 00000000000..c75519cfc7c --- /dev/null +++ b/src/main/git/command-runner/git-admission-output-parity.test.ts @@ -0,0 +1,60 @@ +// Runs on every platform: parity against real git is the Windows-relevant half of +// the admission evidence, so it must not share the storm harness's POSIX gate. +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' +import { _resetGitAdmissionForTests } from './git-subprocess-admission' + +const tempRoots: string[] = [] +const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED + +afterEach(async () => { + if (originalAdmissionDisabled === undefined) { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } else { + process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled + } + _resetGitAdmissionForTests() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function setAdmissionDisabled(disabled: boolean): void { + if (disabled) { + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + } else { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } +} + +it('keeps real git output byte-identical with admission on and bypassed', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-git-output-parity-')) + tempRoots.push(root) + execFileSync('git', ['init', '-q'], { cwd: root }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root }) + execFileSync('git', ['config', 'user.name', 'Orca Test'], { cwd: root }) + await writeFile(path.join(root, 'tracked.txt'), 'line one\nline two\n') + await writeFile(path.join(root, 'blob.bin'), Buffer.from([0, 1, 2, 3, 255])) + execFileSync('git', ['add', '.'], { cwd: root }) + execFileSync('git', ['commit', '-q', '-m', 'fixture'], { cwd: root }) + await writeFile(path.join(root, 'tracked.txt'), 'line one\nchanged\n') + + const runBattery = async (disabled: boolean): Promise<(string | Buffer)[]> => { + setAdmissionDisabled(disabled) + return [ + (await gitExecFileAsync(['status', '--porcelain=v2'], { cwd: root })).stdout, + (await gitExecFileAsync(['diff', '--numstat'], { cwd: root })).stdout, + (await gitExecFileAsync(['branch', '-a'], { cwd: root })).stdout, + (await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: root })).stdout, + (await gitExecFileAsyncBuffer(['show', 'HEAD:blob.bin'], { cwd: root })).stdout + ] + } + + const enabled = await runBattery(false) + const bypassed = await runBattery(true) + expect(bypassed).toEqual(enabled) + expect(await readFile(path.join(root, 'blob.bin'))).toEqual(enabled.at(-1)) + console.info('GIT_ADMISSION_OUTPUT_PARITY=byte-identical') +}) diff --git a/src/main/git/command-runner/git-admission-span.test.ts b/src/main/git/command-runner/git-admission-span.test.ts new file mode 100644 index 00000000000..de74c00ec2e --- /dev/null +++ b/src/main/git/command-runner/git-admission-span.test.ts @@ -0,0 +1,168 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock, span, withGitSpanMock, startGitSpanMock } = vi.hoisted(() => { + const span = { + setAttribute: vi.fn(), + end: vi.fn(), + fail: vi.fn() + } + return { + execFileMock: vi.fn(), + spawnMock: vi.fn(), + span, + withGitSpanMock: vi.fn(async (_attributes: unknown, run: (value: typeof span) => unknown) => { + try { + const result = await run(span) + span.end() + return result + } catch (error) { + span.fail(error) + throw error + } + }), + startGitSpanMock: vi.fn(() => span) + } +}) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock, + spawn: spawnMock +})) +vi.mock('../../observability/instrumentation', () => ({ + withGitSpan: withGitSpanMock, + startGitSpan: startGitSpanMock +})) + +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' +import { withGitAdmission } from './git-spawn' +import { gitStreamStdout } from './git-stream-stdout' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void + +function mockChild(): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = 1234 + child.kill = vi.fn(() => true) + child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + return child as unknown as ChildProcess +} + +async function queueBehindBlocker(): Promise<{ + release: () => void + advance: () => void +}> { + let now = 0 + const scheduler = new GitAdmissionScheduler({ + generalCap: 1, + generalHeadroom: 0, + now: () => now + }) + _resetGitAdmissionForTests(scheduler) + const blocker = await scheduler.acquire({ args: ['status'], cwd: '/blocker' }) + return { + release: blocker.release, + advance: () => { + now = 37 + } + } +} + +async function releaseQueued(blocker: { release: () => void; advance: () => void }): Promise { + await vi.waitFor(() => expect(_gitAdmissionSnapshotForTests().queued).toBe(1)) + blocker.advance() + blocker.release() +} + +describe('git admission span coverage', () => { + beforeEach(() => { + execFileMock.mockReset() + spawnMock.mockReset() + span.setAttribute.mockReset() + span.end.mockReset() + span.fail.mockReset() + withGitSpanMock.mockClear() + startGitSpanMock.mockClear() + }) + + afterEach(() => _resetGitAdmissionForTests()) + + it('records queue wait for string exec inside its span', async () => { + const blocker = await queueBehindBlocker() + const child = mockChild() + let callback: ExecCallback | undefined + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, received: ExecCallback) => { + callback = received + return child + } + ) + const pending = gitExecFileAsync(['status'], { cwd: '/repo' }) + await releaseQueued(blocker) + await vi.waitFor(() => expect(callback).toBeTypeOf('function')) + + child.emit('close', 0, null) + callback?.(null, 'ok', '') + await expect(pending).resolves.toEqual({ stdout: 'ok', stderr: '' }) + expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.end).toHaveBeenCalledOnce() + }) + + it('records queue wait for buffer exec inside its span', async () => { + const blocker = await queueBehindBlocker() + const child = mockChild() + let callback: ExecCallback | undefined + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, received: ExecCallback) => { + callback = received + return child + } + ) + const pending = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo' }) + await releaseQueued(blocker) + await vi.waitFor(() => expect(callback).toBeTypeOf('function')) + + child.emit('close', 0, null) + callback?.(null, Buffer.from('blob'), Buffer.alloc(0)) + await expect(pending).resolves.toEqual({ stdout: Buffer.from('blob') }) + expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.end).toHaveBeenCalledOnce() + }) + + it('records queue wait for stream exec inside its span', async () => { + const blocker = await queueBehindBlocker() + const child = mockChild() + spawnMock.mockReturnValue(child) + const pending = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} }) + await releaseQueued(blocker) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + child.emit('close', 0, null) + await expect(pending).resolves.toEqual({ stoppedEarly: false }) + expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.end).toHaveBeenCalledOnce() + }) + + it('keeps the manual spawn span open through queue wait and child close', async () => { + const blocker = await queueBehindBlocker() + const child = mockChild() + const pending = withGitAdmission(['status'], { cwd: '/repo' }, () => child) + await releaseQueued(blocker) + await expect(pending).resolves.toBe(child) + + expect(span.setAttribute).toHaveBeenCalledWith('git.queue_wait_ms', 37) + expect(span.end).not.toHaveBeenCalled() + child.emit('close', 0, null) + expect(span.end).toHaveBeenCalledOnce() + expect(span.fail).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/command-runner/git-admission-state.ts b/src/main/git/command-runner/git-admission-state.ts new file mode 100644 index 00000000000..60b198660cb --- /dev/null +++ b/src/main/git/command-runner/git-admission-state.ts @@ -0,0 +1,141 @@ +import { availableParallelism } from 'node:os' +import type { GitAdmissionTier } from './git-exec-options' + +export const GENERAL_CAP = Math.max(2, Math.min(4, availableParallelism() - 4)) +export const NETWORK_CAP = 3 +export const GENERAL_HEADROOM = 2 +export const NETWORK_HEADROOM = 1 +export const ROUTE_CAP = 2 +export const ROUTE_HEADROOM = 1 +export const GIT_ADMISSION_AGING_MS = 15_000 +// General 4+2 and network 3+1 are disjoint, so at most ten git children run globally. +export const MAX_GIT_CHILDREN = 10 + +export type AdmissionClass = 'general' | 'network' +export type AdmissionSlotKind = 'base' | 'headroom' + +export type GitAdmissionRequest = { + args: readonly string[] + cwd: string + wslDistro?: string + tier?: GitAdmissionTier + signal?: AbortSignal +} + +export type GitAdmissionGrant = { + queueWaitMs: number + release: () => void +} + +export type AdmissionBudgetSnapshot = { + key: string + baseCapacity: number + headroomCapacity: number + baseUsed: number + headroomUsed: number +} + +export type GitAdmissionEvent = { + sequence: number + phase: 'grant' | 'release' + waiterId: number + args: readonly string[] + tier: GitAdmissionTier + admissionClass: AdmissionClass + route: string | null + slotKind: AdmissionSlotKind + queueWaitMs: number + queued: number + budgets: AdmissionBudgetSnapshot[] +} + +export type AdmissionSchedulerConfig = { + generalCap: number + networkCap: number + generalHeadroom: number + networkHeadroom: number + routeCap: number + routeHeadroom: number + agingMs: number + now: () => number + onAdmissionEvent?: (event: GitAdmissionEvent) => void +} + +export const DEFAULT_ADMISSION_SCHEDULER_CONFIG: AdmissionSchedulerConfig = { + generalCap: GENERAL_CAP, + networkCap: NETWORK_CAP, + generalHeadroom: GENERAL_HEADROOM, + networkHeadroom: NETWORK_HEADROOM, + routeCap: ROUTE_CAP, + routeHeadroom: ROUTE_HEADROOM, + agingMs: GIT_ADMISSION_AGING_MS, + now: () => performance.now() +} + +export type AdmissionBudget = { + baseCapacity: number + headroomCapacity: number + baseUsed: number + headroomUsed: number +} + +export type AdmissionWaiter = { + id: number + args: readonly string[] + tier: GitAdmissionTier + admissionClass: AdmissionClass + route: string | null + enqueuedAt: number + budgetKeys: readonly string[] + signal?: AbortSignal + state: 'queued' | 'granted' | 'settled' + slotKind?: AdmissionSlotKind + resolve: (grant: GitAdmissionGrant) => void + reject: (error: Error) => void + onAbort: () => void +} + +type AdmissionEventDetails = { + waiter: AdmissionWaiter + slotKind: AdmissionSlotKind + phase: GitAdmissionEvent['phase'] + queueWaitMs: number + queued: number + budgets: ReadonlyMap +} + +export class AdmissionEventPublisher { + private nextSequence = 0 + + constructor(private readonly listener?: (event: GitAdmissionEvent) => void) {} + + publish(details: AdmissionEventDetails): void { + if (!this.listener) { + return + } + const event: GitAdmissionEvent = { + sequence: this.nextSequence++, + phase: details.phase, + waiterId: details.waiter.id, + args: details.waiter.args, + tier: details.waiter.tier, + admissionClass: details.waiter.admissionClass, + route: details.waiter.route, + slotKind: details.slotKind, + queueWaitMs: details.queueWaitMs, + queued: details.queued, + budgets: [...details.budgets].map(([key, budget]) => ({ key, ...budget })) + } + try { + this.listener(event) + } catch { + // Measurement must never affect admission. + } + } +} + +export const ADMISSION_TIER_VALUE: Record = { + interactive: 0, + status: 1, + background: 2 +} diff --git a/src/main/git/command-runner/git-admission-storm-measurement.test.ts b/src/main/git/command-runner/git-admission-storm-measurement.test.ts new file mode 100644 index 00000000000..2fb3e896b20 --- /dev/null +++ b/src/main/git/command-runner/git-admission-storm-measurement.test.ts @@ -0,0 +1,250 @@ +/** + * POSIX-only measurement: a PATH-injected git fixture exercises the real spawn path. + * Timing distributions are reported for field comparison; CI assertions stay structural. + */ +import { chmod, mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { gitExecFileAsync } from './git-exec-file' +import { + GIT_ADMISSION_AGING_MS, + GitAdmissionScheduler, + MAX_GIT_CHILDREN, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests, + type GitAdmissionEvent +} from './git-subprocess-admission' + +type StormMeasurement = { + mode: 'disabled' | 'enabled' + maxConcurrentChildren: number + eventLoopMaxDriftMs: number + eventLoopP99DriftMs: number + interactiveP50Ms: number + interactiveP95Ms: number + totalWallMs: number + admissionEvents: GitAdmissionEvent[] + interactiveQueueSnapshots: InteractiveQueueSnapshot[] +} + +type InteractiveQueueSnapshot = { + commandLabel: string + backgroundWaiterIds: number[] +} + +const tempRoots: string[] = [] +const originalAdmissionDisabled = process.env.ORCA_GIT_ADMISSION_DISABLED + +afterEach(async () => { + if (originalAdmissionDisabled === undefined) { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } else { + process.env.ORCA_GIT_ADMISSION_DISABLED = originalAdmissionDisabled + } + _resetGitAdmissionForTests() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +function percentile(values: readonly number[], percentileValue: number): number { + if (values.length === 0) { + return 0 + } + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)] +} + +async function liveChildCount(stateDir: string): Promise { + return (await readdir(stateDir)).filter((name) => name.endsWith('.live')).length +} + +async function createStubGit(root: string): Promise { + const binDir = path.join(root, 'bin') + await mkdir(binDir) + const stubPath = path.join(binDir, 'git') + await writeFile( + stubPath, + `#!/bin/sh +set -eu +live="$ORCA_STUB_STATE_DIR/$ORCA_STUB_ID.live" +: > "$live" +trap 'rm -f "$live"' EXIT HUP INT TERM +sleep "$(awk "BEGIN { print $ORCA_STUB_SLEEP_MS / 1000 }")" +printf 'stub:%s\\n' "$*" +` + ) + await chmod(stubPath, 0o755) + return binDir +} + +function setAdmissionMode(mode: StormMeasurement['mode']): void { + if (mode === 'disabled') { + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + } else { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + } +} + +async function measureStorm(mode: StormMeasurement['mode']): Promise { + setAdmissionMode(mode) + const admissionEvents: GitAdmissionEvent[] = [] + const admissionClockStartedAt = performance.now() + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ + now: () => Math.min(performance.now() - admissionClockStartedAt, GIT_ADMISSION_AGING_MS - 1), + onAdmissionEvent: (event) => admissionEvents.push(event) + }) + ) + const root = await mkdtemp(path.join(tmpdir(), `orca-git-storm-${mode}-`)) + tempRoots.push(root) + const stateDir = path.join(root, 'state') + await mkdir(stateDir) + const binDir = await createStubGit(root) + const repoDirs = await Promise.all( + Array.from({ length: 6 }, async (_, index) => { + const repoDir = path.join(root, `repo-${index}`) + await mkdir(repoDir) + return repoDir + }) + ) + const baseEnv = { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } + const startedAt = performance.now() + const drifts: number[] = [] + let nextJankSample = performance.now() + 50 + const jankTimer = setInterval(() => { + const now = performance.now() + drifts.push(Math.max(0, now - nextJankSample)) + nextJankSample = now + 50 + }, 50) + let maxConcurrentChildren = 0 + const censusTimer = setInterval(() => { + void liveChildCount(stateDir).then((count) => { + maxConcurrentChildren = Math.max(maxConcurrentChildren, count) + }) + }, 5) + + const background = Array.from({ length: 60 }, (_, index) => + gitExecFileAsync(['status', '--porcelain=v2', `storm-${index}`], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `background-${index}`, + ORCA_STUB_SLEEP_MS: index % 10 === 0 ? '5000' : '200', + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'background' + }) + ) + const interactiveQueueSnapshots: InteractiveQueueSnapshot[] = [] + const interactiveLatencies = await Promise.all( + Array.from( + { length: 10 }, + (_, index) => + new Promise((resolve, reject) => { + setTimeout( + () => { + void (async () => { + const concurrentAtInjection = await liveChildCount(stateDir) + const commandStartedAt = performance.now() + const commandLabel = `interactive-${index}` + interactiveQueueSnapshots.push({ + commandLabel, + backgroundWaiterIds: _gitAdmissionSnapshotForTests() + .queuedWaiters.filter((waiter) => waiter.tier === 'background') + .map((waiter) => waiter.id) + }) + await gitExecFileAsync(['rev-parse', commandLabel], { + cwd: repoDirs[index % repoDirs.length], + env: { + ...baseEnv, + ORCA_STUB_ID: `interactive-${index}`, + ORCA_STUB_SLEEP_MS: String(Math.max(10, concurrentAtInjection * 12)), + ORCA_STUB_STATE_DIR: stateDir + }, + admissionTier: 'interactive' + }) + resolve(performance.now() - commandStartedAt) + })().catch(reject) + }, + 50 * (index + 1) + ) + }) + ) + ) + await Promise.all(background) + clearInterval(censusTimer) + clearInterval(jankTimer) + maxConcurrentChildren = Math.max(maxConcurrentChildren, await liveChildCount(stateDir)) + const totalWallMs = performance.now() - startedAt + return { + mode, + maxConcurrentChildren, + eventLoopMaxDriftMs: Math.max(0, ...drifts), + eventLoopP99DriftMs: percentile(drifts, 0.99), + interactiveP50Ms: percentile(interactiveLatencies, 0.5), + interactiveP95Ms: percentile(interactiveLatencies, 0.95), + totalWallMs, + admissionEvents, + interactiveQueueSnapshots + } +} + +function formatMeasurementTable(rows: readonly StormMeasurement[]): string { + const rounded = rows.map((row) => ({ + mode: row.mode, + maxConcurrentChildren: row.maxConcurrentChildren, + eventLoopMaxDriftMs: row.eventLoopMaxDriftMs.toFixed(1), + eventLoopP99DriftMs: row.eventLoopP99DriftMs.toFixed(1), + interactiveP50Ms: row.interactiveP50Ms.toFixed(1), + interactiveP95Ms: row.interactiveP95Ms.toFixed(1), + totalWallMs: row.totalWallMs.toFixed(1) + })) + return JSON.stringify(rounded) +} + +function assertAdmissionLedger(measurement: StormMeasurement): void { + const activeWaiters = new Set() + const grantSequenceByWaiter = new Map() + const grantByLabel = new Map() + + measurement.admissionEvents.forEach((event, index) => { + expect(event.sequence).toBe(index) + if (event.phase === 'grant') { + activeWaiters.add(event.waiterId) + grantSequenceByWaiter.set(event.waiterId, event.sequence) + const label = event.args.find((arg) => arg.startsWith('interactive-')) + if (label) { + grantByLabel.set(label, event) + } + } else { + expect(activeWaiters.delete(event.waiterId)).toBe(true) + } + expect(activeWaiters.size).toBeLessThanOrEqual(MAX_GIT_CHILDREN) + for (const budget of event.budgets) { + expect(budget.baseUsed).toBeLessThanOrEqual(budget.baseCapacity) + expect(budget.headroomUsed).toBeLessThanOrEqual(budget.headroomCapacity) + } + }) + expect(activeWaiters.size).toBe(0) + + for (const snapshot of measurement.interactiveQueueSnapshots) { + const interactiveGrant = grantByLabel.get(snapshot.commandLabel) + expect(interactiveGrant).toBeDefined() + const preceded = snapshot.backgroundWaiterIds.filter( + (waiterId) => interactiveGrant!.sequence < (grantSequenceByWaiter.get(waiterId) ?? Infinity) + ).length + expect(preceded).toBeGreaterThanOrEqual(Math.ceil(snapshot.backgroundWaiterIds.length * 0.9)) + } +} + +describe.skipIf(process.platform === 'win32')('git admission storm measurement', () => { + it('reports bounded-concurrency before and after measurements', async () => { + const disabled = await measureStorm('disabled') + const enabled = await measureStorm('enabled') + console.info(`GIT_ADMISSION_STORM_MEASUREMENT=${formatMeasurementTable([disabled, enabled])}`) + assertAdmissionLedger(enabled) + }) + + // Output parity lives in git-admission-output-parity.test.ts: it needs real git, + // not the PATH stub, so it runs on win32 too and cannot share this file's gate. +}) diff --git a/src/main/git/command-runner/git-admission-waiter-queue.ts b/src/main/git/command-runner/git-admission-waiter-queue.ts new file mode 100644 index 00000000000..1f0e9a55cde --- /dev/null +++ b/src/main/git/command-runner/git-admission-waiter-queue.ts @@ -0,0 +1,249 @@ +import type { AdmissionClass, AdmissionSlotKind, AdmissionWaiter } from './git-admission-state' +import { CandidateHeap, type Candidate, type WaiterLane } from './git-admission-candidate-heap' +import type { GitAdmissionTier } from './git-exec-options' + +type SelectedWaiter = { + waiter: AdmissionWaiter + slotKind: AdmissionSlotKind +} + +const TIERS = ['interactive', 'status', 'background'] as const +const createLane = (): WaiterLane => ({ + items: [], + head: 0, + count: 0, + baseEligible: false, + headroomEligible: false, + version: 0 +}) +type TierLanes = Record> +const createTierLanes = (): TierLanes => ({ + interactive: new Map(), + status: new Map(), + background: new Map() +}) +export class GitAdmissionWaiterQueue { + private readonly lanes: Record = { + general: createTierLanes(), + network: createTierLanes() + } + private readonly countsByBudget = new Map() + private readonly baseCandidates: Record> = + { + general: { + interactive: new CandidateHeap(), + status: new CandidateHeap(), + background: new CandidateHeap() + }, + network: { + interactive: new CandidateHeap(), + status: new CandidateHeap(), + background: new CandidateHeap() + } + } + private readonly headroomCandidates: Record = { + general: new CandidateHeap(), + network: new CandidateHeap() + } + private totalCount = 0 + + get count(): number { + return this.totalCount + } + + /** @internal - exposed for bounded-storage regression tests only. */ + get candidateCountForTests(): number { + let count = this.headroomCandidates.general.size + this.headroomCandidates.network.size + for (const admissionClass of ['general', 'network'] as const) { + for (const tier of TIERS) { + count += this.baseCandidates[admissionClass][tier].size + } + } + return count + } + + enqueue(waiter: AdmissionWaiter): void { + const lanes = this.lanes[waiter.admissionClass][waiter.tier] + let lane = lanes.get(waiter.route) + if (!lane) { + lane = createLane() + lanes.set(waiter.route, lane) + } + lane.items.push(waiter) + lane.count += 1 + this.totalCount += 1 + for (const key of waiter.budgetKeys) { + this.countsByBudget.set(key, (this.countsByBudget.get(key) ?? 0) + 1) + } + if (lane.count === 1) { + this.publishLaneHead(waiter.admissionClass, waiter.tier, lane) + } + } + + dequeue(waiter: AdmissionWaiter): void { + const lanes = this.lanes[waiter.admissionClass][waiter.tier] + const lane = lanes.get(waiter.route) + if (!lane) { + return + } + lane.count -= 1 + this.totalCount -= 1 + for (const key of waiter.budgetKeys) { + const current = this.countsByBudget.get(key) + if (current === 1) { + this.countsByBudget.delete(key) + } else if (current !== undefined) { + this.countsByBudget.set(key, current - 1) + } + } + const previousHead = lane.items[lane.head] + this.compact(lane) + if (lane.count === 0) { + lane.version += 1 + lanes.delete(waiter.route) + this.compactCandidateHeaps(waiter.admissionClass, waiter.tier) + } else if (lane.items[lane.head] !== previousHead) { + lane.version += 1 + this.publishLaneHead(waiter.admissionClass, waiter.tier, lane) + this.compactCandidateHeaps(waiter.admissionClass, waiter.tier) + } + } + + updateRouteEligibility( + admissionClass: AdmissionClass, + route: string | null, + baseEligible: boolean, + headroomEligible: boolean + ): void { + for (const tier of TIERS) { + const lane = this.lanes[admissionClass][tier].get(route) + if ( + !lane || + (lane.baseEligible === baseEligible && lane.headroomEligible === headroomEligible) + ) { + continue + } + lane.baseEligible = baseEligible + lane.headroomEligible = headroomEligible + lane.version += 1 + this.publishLaneHead(admissionClass, tier, lane) + this.compactCandidateHeaps(admissionClass, tier) + } + } + + hasBudget(key: string): boolean { + return this.countsByBudget.has(key) + } + + snapshot(): AdmissionWaiter[] { + return (['general', 'network'] as const) + .flatMap((admissionClass) => + TIERS.flatMap((tier) => { + return [...this.lanes[admissionClass][tier].values()].flatMap((lane) => + lane.items.slice(lane.head).filter((waiter) => waiter.state === 'queued') + ) + }) + ) + .sort((left, right) => left.id - right.id) + } + + nextFitting( + admissionClass: AdmissionClass, + effectiveTier: (waiter: AdmissionWaiter) => number, + allowBase: boolean, + allowHeadroom: boolean, + abort: (waiter: AdmissionWaiter) => void + ): SelectedWaiter | null { + while (true) { + const candidates: SelectedWaiter[] = [] + if (allowBase) { + // Aging preserves order within a raw tier, so only its oldest eligible head can win. + for (const tier of TIERS) { + const candidate = this.peekValid(this.baseCandidates[admissionClass][tier], 'base') + if (candidate) { + candidates.push({ waiter: candidate.waiter, slotKind: 'base' }) + } + } + } + if (allowHeadroom) { + const candidate = this.peekValid(this.headroomCandidates[admissionClass], 'headroom') + if (candidate) { + candidates.push({ waiter: candidate.waiter, slotKind: 'headroom' }) + } + } + const selected = candidates.sort( + (left, right) => + effectiveTier(left.waiter) - effectiveTier(right.waiter) || + left.waiter.id - right.waiter.id || + (left.slotKind === 'base' ? -1 : 1) + )[0] + if (!selected || !selected.waiter.signal?.aborted) { + return selected ?? null + } + abort(selected.waiter) + } + } + + private publishLaneHead( + admissionClass: AdmissionClass, + tier: GitAdmissionTier, + lane: WaiterLane + ): void { + const waiter = lane.items[lane.head] + if (!waiter || waiter.state !== 'queued') { + return + } + const candidate = { lane, waiter, version: lane.version } + if (lane.baseEligible) { + this.baseCandidates[admissionClass][tier].push(candidate) + } + if (tier === 'interactive' && lane.headroomEligible) { + this.headroomCandidates[admissionClass].push(candidate) + } + } + + private peekValid(heap: CandidateHeap, slotKind: AdmissionSlotKind): Candidate | null { + return heap.peek((candidate) => this.candidateIsValid(candidate, slotKind)) + } + + private candidateIsValid(candidate: Candidate, slotKind: AdmissionSlotKind): boolean { + const { lane, waiter, version } = candidate + return ( + version === lane.version && + waiter === lane.items[lane.head] && + waiter.state === 'queued' && + (slotKind === 'base' ? lane.baseEligible : lane.headroomEligible) + ) + } + + private compactCandidateHeaps(admissionClass: AdmissionClass, tier: GitAdmissionTier): void { + const liveLaneCount = this.lanes[admissionClass][tier].size + this.baseCandidates[admissionClass][tier].compactIfOversized(liveLaneCount, (candidate) => + this.candidateIsValid(candidate, 'base') + ) + if (tier === 'interactive') { + this.headroomCandidates[admissionClass].compactIfOversized(liveLaneCount, (candidate) => + this.candidateIsValid(candidate, 'headroom') + ) + } + } + + private compact(lane: WaiterLane): void { + let head = lane.head + while (head < lane.items.length && lane.items[head].state !== 'queued') { + head += 1 + } + lane.head = head + if (lane.count === 0) { + lane.items.length = 0 + lane.head = 0 + return + } + const tombstones = lane.items.length - head - lane.count + if (head < 256 && (lane.items.length < 256 || tombstones <= lane.count)) { + return + } + lane.items = lane.items.slice(head).filter((candidate) => candidate.state === 'queued') + lane.head = 0 + } +} diff --git a/src/main/git/command-runner/git-command-timeout-behavior.test.ts b/src/main/git/command-runner/git-command-timeout-behavior.test.ts new file mode 100644 index 00000000000..95330da9666 --- /dev/null +++ b/src/main/git/command-runner/git-command-timeout-behavior.test.ts @@ -0,0 +1,144 @@ +import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' +import { GitCommandTimeoutError } from './git-command-timeout' +import { gitStreamStdout } from './git-stream-stdout' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function createTimedGitFixture(): Promise<{ + cwd: string + env: NodeJS.ProcessEnv +}> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-git-timeout-')) + tempRoots.push(root) + const binDir = path.join(root, 'bin') + const cwd = path.join(root, 'repo') + await mkdir(binDir) + await mkdir(cwd) + const script = path.join(binDir, 'git') + await writeFile( + script, + `#!/usr/bin/env node +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) +async function run() { + if (process.env.ORCA_STUB_PROGRESSIVE === '1') { + const remaining = Math.max(0, Number(process.env.ORCA_STUB_EXIT_AT_MS) - Date.now()) + const step = remaining / 3 + await sleep(step) + process.stdout.write('one') + await sleep(step) + process.stdout.write('two') + await sleep(step) + process.stdout.write('three') + return + } + await sleep(Number(process.env.ORCA_STUB_SLEEP_MS)) + process.stdout.write('done') +} +void run() +` + ) + await chmod(script, 0o755) + return { + cwd, + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}` } + } +} + +describe.skipIf(process.platform === 'win32')('git read timeout behavior', () => { + it('allows a progressively streaming read that exits at 0.9 times the deadline', async () => { + const fixture = await createTimedGitFixture() + let output = '' + await expect( + gitStreamStdout(['status', '--porcelain=v2'], { + ...fixture, + env: { + ...fixture.env, + ORCA_STUB_PROGRESSIVE: '1', + ORCA_STUB_EXIT_AT_MS: String(Date.now() + 900) + }, + timeoutMsForTest: 1000, + onStdout: (chunk) => { + output += chunk + } + }) + ).resolves.toEqual({ stoppedEarly: false }) + expect(output).toBe('onetwothree') + }) + + it('rejects a silent read at 1.1 times the deadline with a typed error', async () => { + const fixture = await createTimedGitFixture() + await expect( + gitExecFileAsync(['status', '--porcelain=v2'], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' }, + timeoutMsForTest: 100 + }) + ).rejects.toBeInstanceOf(GitCommandTimeoutError) + }) + + it('times out a silent streaming read with the same typed error', async () => { + const fixture = await createTimedGitFixture() + await expect( + gitStreamStdout(['status'], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' }, + timeoutMsForTest: 100, + onStdout: () => {} + }) + ).rejects.toBeInstanceOf(GitCommandTimeoutError) + }) + + it('applies the read default to binary blob reads', async () => { + const fixture = await createTimedGitFixture() + await expect( + gitExecFileAsyncBuffer(['show', 'HEAD:file.bin'], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' }, + timeoutMsForTest: 100 + }) + ).rejects.toBeInstanceOf(GitCommandTimeoutError) + }) + + it.each([['fetch'], ['checkout'], ['unrecognized-command']])( + 'does not default-timeout the fail-safe %s class', + async (subcommand) => { + const fixture = await createTimedGitFixture() + await expect( + gitExecFileAsync([subcommand], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '110' }, + timeoutMsForTest: 100 + }) + ).resolves.toMatchObject({ stdout: 'done' }) + } + ) +}) + +describe.skipIf(process.platform === 'win32' || process.env.ORCA_RUN_SLOW_GIT_SMOKE !== '1')( + 'slow git read timeout smoke', + () => { + it('allows a 90 second read and terminates a 130 second wedge', async () => { + const fixture = await createTimedGitFixture() + await expect( + gitExecFileAsync(['status'], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '90000' } + }) + ).resolves.toMatchObject({ stdout: 'done' }) + await expect( + gitExecFileAsync(['status'], { + ...fixture, + env: { ...fixture.env, ORCA_STUB_SLEEP_MS: '130000' } + }) + ).rejects.toBeInstanceOf(GitCommandTimeoutError) + }, 230_000) + } +) diff --git a/src/main/git/command-runner/git-command-timeout.test.ts b/src/main/git/command-runner/git-command-timeout.test.ts new file mode 100644 index 00000000000..1768198a81a --- /dev/null +++ b/src/main/git/command-runner/git-command-timeout.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + GIT_READ_TIMEOUT_MS, + GitCommandTimeoutError, + gitCommandTimeoutMs +} from './git-command-timeout' + +describe('gitCommandTimeoutMs', () => { + it('pins the production read deadline', () => { + expect(GIT_READ_TIMEOUT_MS).toBe(120_000) + }) + + it.each([ + [['status', '--porcelain=v2'], 120_000], + [['show', 'HEAD:file'], 120_000], + [['fetch', 'origin'], undefined], + [['checkout', 'main'], undefined], + [['unknown'], undefined] + ] as const)('selects the fail-safe default for %j', (args, expected) => { + expect(gitCommandTimeoutMs(args, undefined)).toBe(expected) + }) + + it('preserves every explicit timeout', () => { + expect(gitCommandTimeoutMs(['status'], 7)).toBe(7) + expect(gitCommandTimeoutMs(['fetch'], 7)).toBe(7) + }) + + it('provides a deterministic read-timeout seam', () => { + expect(gitCommandTimeoutMs(['status'], undefined, 25)).toBe(25) + }) +}) + +describe('GitCommandTimeoutError', () => { + it('is typed and retains its deadline', () => { + const error = new GitCommandTimeoutError(25) + expect(error).toBeInstanceOf(Error) + expect(error).toMatchObject({ name: 'GitCommandTimeoutError', timeoutMs: 25 }) + }) +}) diff --git a/src/main/git/command-runner/git-command-timeout.ts b/src/main/git/command-runner/git-command-timeout.ts new file mode 100644 index 00000000000..da8acd61bc9 --- /dev/null +++ b/src/main/git/command-runner/git-command-timeout.ts @@ -0,0 +1,23 @@ +import { classifyGitCommand } from '../wsl-direct-git-read-commands' + +export const GIT_READ_TIMEOUT_MS = 120_000 + +export class GitCommandTimeoutError extends Error { + readonly timeoutMs: number + + constructor(timeoutMs: number) { + super('git timed out.') + this.name = 'GitCommandTimeoutError' + this.timeoutMs = timeoutMs + } +} + +export function gitCommandTimeoutMs( + args: readonly string[], + explicitTimeoutMs: number | undefined, + defaultReadTimeoutMs = GIT_READ_TIMEOUT_MS +): number | undefined { + return ( + explicitTimeoutMs ?? (classifyGitCommand(args) === 'read' ? defaultReadTimeoutMs : undefined) + ) +} diff --git a/src/main/git/command-runner/git-exec-admission-lifetime.test.ts b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts new file mode 100644 index 00000000000..bba29a4adaa --- /dev/null +++ b/src/main/git/command-runner/git-exec-admission-lifetime.test.ts @@ -0,0 +1,237 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + execFileMock, + spawnMock, + killSpawnedCommandTreeMock, + signalProcessTreeMock, + forceTerminateProcessTreeMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn(), + killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined), + signalProcessTreeMock: vi.fn().mockResolvedValue(false), + forceTerminateProcessTreeMock: vi.fn().mockResolvedValue(false) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock, + spawn: spawnMock +})) +vi.mock('./spawned-command-tree-kill', () => ({ + killSpawnedCommandTree: killSpawnedCommandTreeMock +})) +vi.mock('../../../shared/child-process/process-tree-termination', () => ({ + signalProcessTree: signalProcessTreeMock, + forceTerminateProcessTree: forceTerminateProcessTreeMock +})) + +import { gitExecFileAsync, gitExecFileAsyncBuffer } from './git-exec-file' +import { execFileCapture } from './exec-file-capture' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +type ExecCallback = (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void + +function mockChild(pid: number | undefined = 1234): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = pid + child.kill = vi.fn(() => true) + child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + return child as unknown as ChildProcess +} + +describe('git exec admission lifetime', () => { + beforeEach(() => { + vi.useFakeTimers() + execFileMock.mockReset() + spawnMock.mockReset() + killSpawnedCommandTreeMock.mockClear() + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })) + }) + + afterEach(() => { + vi.useRealTimers() + _resetGitAdmissionForTests() + }) + + it('retains the string-exec permit after timeout settlement until close', async () => { + const child = mockChild() + execFileMock.mockReturnValue(child) + const pending = gitExecFileAsync(['status'], { cwd: '/repo', timeout: 10 }) + const rejection = expect(pending).rejects.toThrow('timed out') + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + + await vi.advanceTimersByTimeAsync(10) + await rejection + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', null, 'SIGKILL') + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('retains the buffer-exec permit after maxBuffer settlement until close', async () => { + const child = mockChild() + let callback: ExecCallback | undefined + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, received: ExecCallback) => { + callback = received + return child + } + ) + const pending = gitExecFileAsyncBuffer(['show', 'HEAD:file'], { cwd: '/repo' }) + await vi.waitFor(() => expect(callback).toBeTypeOf('function')) + + callback?.(new Error('maxBuffer exceeded'), Buffer.alloc(0), Buffer.alloc(0)) + await expect(pending).rejects.toThrow('maxBuffer exceeded') + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', null, 'SIGTERM') + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('does not admit a same-repo fetch while it waits for the FETCH_HEAD lock', async () => { + _resetGitAdmissionForTests(new GitAdmissionScheduler({ networkCap: 2, networkHeadroom: 0 })) + const children = [mockChild(1001), mockChild(1002)] + const callbacks: ExecCallback[] = [] + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callbacks.push(callback) + return children[callbacks.length - 1] + } + ) + + const first = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' }) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + const second = gitExecFileAsync(['fetch', 'origin'], { cwd: '/same-repo' }) + await Promise.resolve() + + expect(execFileMock).toHaveBeenCalledOnce() + expect(_gitAdmissionSnapshotForTests()).toMatchObject({ + queued: 0, + budgets: { network: { baseUsed: 1, headroomUsed: 0 } } + }) + + callbacks[0]?.(null, '', '') + children[0].emit('close', 0, null) + await expect(first).resolves.toEqual({ stdout: '', stderr: '' }) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + callbacks[1]?.(null, '', '') + children[1].emit('close', 0, null) + await expect(second).resolves.toEqual({ stdout: '', stderr: '' }) + expect(_gitAdmissionSnapshotForTests().budgets.network?.baseUsed).toBe(0) + }) + + it('reports pre-aborted capture as a no-child termination', async () => { + const controller = new AbortController() + const onChildTerminated = vi.fn() + controller.abort() + + await expect( + execFileCapture('git', ['status'], { + signal: controller.signal, + onChildTerminated + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + expect(onChildTerminated).toHaveBeenCalledOnce() + }) + + it('releases termination-barrier admission on confirmed close', async () => { + const child = mockChild() + spawnMock.mockReturnValue(child) + const pending = gitExecFileAsync(['status'], { + cwd: '/repo', + terminationBarrier: true + }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', 0, null) + await expect(pending).resolves.toEqual({ stdout: '', stderr: '' }) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('retains barrier admission past bounded settlement until termination is observed', async () => { + const child = mockChild() + spawnMock.mockReturnValue(child) + const pending = gitExecFileAsync(['status'], { + cwd: '/repo', + terminationBarrier: true, + timeout: 10 + }) + const rejection = expect(pending).rejects.toThrow('timed out') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) + + await vi.advanceTimersByTimeAsync(2010) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + await vi.advanceTimersByTimeAsync(10_000) + await rejection + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', null, 'SIGKILL') + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('serializes FETCH_HEAD callers before they enter admission', async () => { + _resetGitAdmissionForTests(new GitAdmissionScheduler({ networkCap: 1, networkHeadroom: 1 })) + const children = new Map() + const callbacks = new Map() + execFileMock.mockImplementation( + (_command: string, args: string[], _options: unknown, callback: ExecCallback) => { + const label = args[1] ?? '' + const child = mockChild() + children.set(label, child) + callbacks.set(label, callback) + return child + } + ) + + const first = gitExecFileAsync(['fetch', 'first'], { + cwd: '/repo', + admissionTier: 'background' + }) + await vi.waitFor(() => expect(callbacks.has('first')).toBe(true)) + const background = gitExecFileAsync(['fetch', 'background'], { + cwd: '/repo', + admissionTier: 'background' + }) + const interactive = gitExecFileAsync(['fetch', 'interactive'], { + cwd: '/repo', + admissionTier: 'interactive' + }) + + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().queued).toBe(0) + expect([...callbacks.keys()]).toEqual(['first']) + + callbacks.get('first')?.(null, '', '') + await expect(first).resolves.toEqual({ stdout: '', stderr: '' }) + await vi.waitFor(() => expect(_gitAdmissionSnapshotForTests().queued).toBe(1)) + expect([...callbacks.keys()]).toEqual(['first']) + + children.get('first')?.emit('close', 0, null) + await vi.waitFor(() => expect(callbacks.has('background')).toBe(true)) + expect([...callbacks.keys()]).toEqual(['first', 'background']) + + callbacks.get('background')?.(null, '', '') + await expect(background).resolves.toEqual({ stdout: '', stderr: '' }) + await vi.waitFor(() => expect(callbacks.has('interactive')).toBe(true)) + + children.get('background')?.emit('close', 0, null) + callbacks.get('interactive')?.(null, '', '') + await expect(interactive).resolves.toEqual({ stdout: '', stderr: '' }) + children.get('interactive')?.emit('close', 0, null) + }) +}) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index d9762030813..4fb28760d61 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -10,7 +10,7 @@ import { prepareWslLinkedWorktreeGitRouting } from '../wsl-linked-worktree-git-routing' import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' -import type { GitExecOptions } from './git-exec-options' +import type { GitAdmissionTier, GitExecOptions } from './git-exec-options' import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture' import { pendingWslDirectGitReadEnvironment, @@ -22,6 +22,8 @@ import { import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' import { buildNetworkSshPolicyEnv } from './git-ssh-policy-env' import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env' +import { acquireGitAdmission } from './git-subprocess-admission' +import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout' /** * Async git command execution. Drop-in replacement for @@ -34,7 +36,7 @@ async function gitExecFileAsyncUnlocked( // Why: span the user-visible `git ` form, not the resolved binary, so dashboards group by intent. return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, - async () => { + async (span) => { if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { signal: options.signal @@ -61,18 +63,37 @@ async function gitExecFileAsyncUnlocked( const policy = effectiveOptions.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(effectiveOptions) : { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const } + const grant = await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) + span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) + const terminationState: { current: Promise | null } = { current: null } const capture = ( command: ResolvedCommand ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> => { + let reportTerminated: () => void = () => {} + terminationState.current = new Promise((resolve) => { + reportTerminated = resolve + }) const captureOptions = { cwd: command.cwd, encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, - timeout: options.timeout, + timeout: timeoutMs, stdin: options.stdin, env: policy.env, signal: options.signal, - terminationBarrier: options.terminationBarrier + terminationBarrier: options.terminationBarrier, + admissionTier: options.admissionTier, + onChildTerminated: reportTerminated, + ...(timeoutMs === undefined + ? {} + : { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) }) } return options.terminationBarrier ? execFileCaptureToTermination( @@ -83,34 +104,50 @@ async function gitExecFileAsyncUnlocked( ) : execFileCapture(command.binary, command.args, captureOptions) } - let result: { stdout: string | Buffer; stderr: string | Buffer } - try { - result = await capture(resolved) - } catch (error) { - if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { - const wasMissing = invalidateMissingDirectWslGit(error, resolved) - const fallback = resolveGitCommand( - args, - effectiveOptions, - true, - effectiveOptions.captureWslLoginShellOutput - ) - result = await capture(fallback) - // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. - disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) - const { stdout, stderr } = result - return { - stdout: readCapturedGitString(stdout as string, fallback), - stderr: stderr as string + const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => { + let result: { stdout: string | Buffer; stderr: string | Buffer } + try { + result = await capture(resolved) + } catch (error) { + if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) { + await terminationState.current + const wasMissing = invalidateMissingDirectWslGit(error, resolved) + const fallback = resolveGitCommand( + args, + effectiveOptions, + true, + effectiveOptions.captureWslLoginShellOutput + ) + result = await capture(fallback) + // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. + disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) + const { stdout, stderr } = result + return { + stdout: readCapturedGitString(stdout as string, fallback), + stderr: stderr as string + } } + if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { + Object.assign(error, { gitSshPolicyMode: policy.mode }) + } + throw error } - if (options.useConfiguredSshCommandForNetwork && error && typeof error === 'object') { - Object.assign(error, { gitSshPolicyMode: policy.mode }) + const { stdout, stderr } = result + return { + stdout: readCapturedGitString(stdout as string, resolved), + stderr: stderr as string + } + } + try { + return await runCapturedCommand() + } finally { + const termination = terminationState.current + if (termination) { + void termination.then(grant.release) + } else { + grant.release() } - throw error } - const { stdout, stderr } = result - return { stdout: readCapturedGitString(stdout as string, resolved), stderr: stderr as string } } ) } @@ -119,11 +156,15 @@ export function gitExecFileAsync( args: string[], options: GitExecOptions ): Promise<{ stdout: string; stderr: string }> { - const run = () => gitExecFileAsyncUnlocked(args, options) const command = resolveGitFetchHeadCommand(args, options.cwd) return command.needsLock - ? runWithGitFetchHeadLock(command.cwd, options.signal, run, command.gitDir) - : run() + ? runWithGitFetchHeadLock( + command.cwd, + options.signal, + () => gitExecFileAsyncUnlocked(args, options), + command.gitDir + ) + : gitExecFileAsyncUnlocked(args, options) } /** @@ -132,31 +173,69 @@ export function gitExecFileAsync( */ export async function gitExecFileAsyncBuffer( args: string[], - options: { cwd: string; maxBuffer?: number; wslDistro?: string; preferWslDirectGit?: boolean } + options: { + cwd: string + maxBuffer?: number + timeout?: number + timeoutMsForTest?: number + env?: NodeJS.ProcessEnv + wslDistro?: string + preferWslDirectGit?: boolean + admissionTier?: GitAdmissionTier + } ): Promise<{ stdout: Buffer }> { - if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { - await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) - } - const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) - if (readEnvironmentReady) { - await readEnvironmentReady - } - // `git show` is a read, so this normally runs with no shell at all. The fence - // still matters for the login-shell fallback: these are raw blob bytes going - // straight to the diff/blob viewer, where a banner becomes file content. - let resolved = resolveGitCommand(args, options, false, true) - const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) - if (environmentReady) { - await environmentReady - } - resolved = resolveGitCommand(args, options, false, true) - const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: 'buffer', - maxBuffer: options.maxBuffer, - env: untranslatedGitOutputEnv() - })) as { stdout: Buffer } - return { stdout: readCapturedGitBuffer(stdout, resolved) } + return withGitSpan({ args, cwd: options.cwd }, async (span) => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options) + if (readEnvironmentReady) { + await readEnvironmentReady + } + // `git show` is a read, so this normally runs with no shell at all. The fence + // still matters for the login-shell fallback: these are raw blob bytes going + // straight to the diff/blob viewer, where a banner becomes file content. + let resolved = resolveGitCommand(args, options, false, true) + const environmentReady = prepareWindowsHostGitEnvironment(resolved, undefined) + if (environmentReady) { + await environmentReady + } + resolved = resolveGitCommand(args, options, false, true) + const grant = await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier + }) + span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest) + let termination: Promise | null = null + try { + let reportTerminated: () => void = () => {} + termination = new Promise((resolve) => { + reportTerminated = resolve + }) + const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: 'buffer', + maxBuffer: options.maxBuffer, + timeout: timeoutMs, + env: untranslatedGitOutputEnv(options.env), + admissionTier: options.admissionTier, + onChildTerminated: reportTerminated, + ...(timeoutMs === undefined + ? {} + : { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) }) + })) as { stdout: Buffer } + return { stdout: readCapturedGitBuffer(stdout, resolved) } + } finally { + if (termination) { + void termination.then(grant.release) + } else { + grant.release() + } + } + }) } /** diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts index 1055721475c..4390d84658d 100644 --- a/src/main/git/command-runner/git-exec-options.ts +++ b/src/main/git/command-runner/git-exec-options.ts @@ -1,11 +1,15 @@ // 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 +export type GitAdmissionTier = 'interactive' | 'status' | 'background' + export type GitExecOptions = { cwd: string encoding?: BufferEncoding | 'buffer' maxBuffer?: number timeout?: number + /** Overrides only the default read deadline in tests; explicit timeout still wins. */ + timeoutMsForTest?: number stdin?: string env?: NodeJS.ProcessEnv signal?: AbortSignal @@ -14,4 +18,6 @@ export type GitExecOptions = { useConfiguredSshCommandForNetwork?: boolean terminationBarrier?: boolean captureWslLoginShellOutput?: boolean + /** Scheduler priority for this child; status is the safe default. */ + admissionTier?: GitAdmissionTier } diff --git a/src/main/git/command-runner/git-spawn-admission-lifetime.test.ts b/src/main/git/command-runner/git-spawn-admission-lifetime.test.ts new file mode 100644 index 00000000000..1648894ad61 --- /dev/null +++ b/src/main/git/command-runner/git-spawn-admission-lifetime.test.ts @@ -0,0 +1,79 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { withGitAdmission } from './git-spawn' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +function mockChild(pid: number | undefined = 1234): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = pid + child.kill = vi.fn(() => true) + return child as unknown as ChildProcess +} + +describe('git spawn admission lifetime', () => { + beforeEach(() => { + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })) + }) + + afterEach(() => _resetGitAdmissionForTests()) + + it('releases a normally closed child exactly once', async () => { + const child = mockChild() + await withGitAdmission(['status'], { cwd: '/repo' }, () => child) + + child.emit('close', 0, null) + child.emit('close', 0, null) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('retains an early-killed child until eventual close', async () => { + const child = mockChild() + await withGitAdmission(['status'], { cwd: '/repo' }, () => child) + + child.kill() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + child.emit('close', null, 'SIGTERM') + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('retains a live child error until eventual close and releases once', async () => { + const child = mockChild() + await withGitAdmission(['status'], { cwd: '/repo' }, () => child) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('error', new Error('kill delivery failed')) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + child.emit('close', null, 'SIGKILL') + child.emit('close', null, 'SIGKILL') + + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('releases a no-PID spawn error without waiting for close', async () => { + const child = mockChild(0) + await withGitAdmission(['status'], { cwd: '/repo' }, () => child) + child.emit('error', new Error('ENOENT')) + + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('aborts while queued without releasing the running child', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetGitAdmissionForTests(scheduler) + const running = await scheduler.acquire({ args: ['status'], cwd: '/repo' }) + const controller = new AbortController() + const pending = withGitAdmission(['status'], { cwd: '/repo', signal: controller.signal }, () => + mockChild() + ) + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + running.release() + }) +}) diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index 47d761b9964..47ccb501019 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -1,15 +1,22 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe' +import { startGitSpan } from '../../observability/instrumentation' import { createAbortError } from './abort-error' import { resolveGitCommand } from './git-command-resolution' import { untranslatedGitOutputEnv } from './git-process-env' import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' +import type { GitAdmissionTier } from './git-exec-options' +import { acquireGitAdmission } from './git-subprocess-admission' /** * Spawn a git child process. Drop-in replacement for * `spawn('git', args, { cwd, stdio, ... })`. */ -export type GitSpawnOptions = SpawnOptions & { cwd: string; wslDistro?: string } +export type GitSpawnOptions = SpawnOptions & { + cwd: string + wslDistro?: string + admissionTier?: GitAdmissionTier +} export async function gitSpawnAfterWindowsEnvironmentReady( args: string[], @@ -28,11 +35,74 @@ export async function gitSpawnAfterWindowsEnvironmentReady( if (options.signal?.aborted) { throw createAbortError() } - return gitSpawn(args, env === options.env ? options : { ...options, env }) + return withGitAdmission(args, env === options.env ? options : { ...options, env }) +} + +export async function withGitAdmission( + args: string[], + options: GitSpawnOptions, + spawnChild: () => ChildProcess = () => gitSpawn(args, options) +): Promise { + const span = startGitSpan({ args, cwd: options.cwd }) + let grant: Awaited> | null = null + try { + grant = await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) + span.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + if (options.signal?.aborted) { + grant.release() + span.fail(createAbortError()) + throw createAbortError() + } + const child = spawnChild() + let finalized = false + let liveError: Error | null = null + const finalize = (error?: Error): void => { + if (finalized) { + return + } + finalized = true + child.off('error', handleError) + child.off('close', handleClose) + grant?.release() + if (error) { + span.fail(error) + } else { + span.end() + } + } + const handleError = (error: Error): void => { + if (!child.pid) { + finalize(error) + } else { + liveError = error + } + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + const exitError = + liveError ?? + (code === 0 && signal === null + ? undefined + : new Error(`git exited with ${code ?? signal ?? 'unknown'}.`)) + finalize(exitError) + } + child.on('error', handleError) + child.once('close', handleClose) + return child + } catch (error) { + grant?.release() + span.fail(error instanceof Error ? error : new Error(String(error))) + throw error + } } export function gitSpawn(args: string[], options: GitSpawnOptions): ChildProcess { - const { wslDistro, ...spawnOptions } = options + const { wslDistro, admissionTier: _admissionTier, ...spawnOptions } = options const resolved = resolveGitCommand(args, { cwd: options.cwd, ...(wslDistro ? { wslDistro } : {}), diff --git a/src/main/git/command-runner/git-ssh-policy-env.ts b/src/main/git/command-runner/git-ssh-policy-env.ts index faece9ab141..a5d8637bf9e 100644 --- a/src/main/git/command-runner/git-ssh-policy-env.ts +++ b/src/main/git/command-runner/git-ssh-policy-env.ts @@ -4,6 +4,7 @@ import { execFileCapture } from './exec-file-capture' import { resolveGitCommand } from './git-command-resolution' import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' import { promptGuardGitEnv } from './git-process-env' +import { acquireGitAdmission } from './git-subprocess-admission' export type GitSshPolicyMode = | 'default' @@ -131,6 +132,18 @@ export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise // Why fenced: a login-shell banner here reads as a user-configured sshCommand, // which skips the BatchMode fallback below and disarms the no-prompt guard. const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true, true) + const probeArgs = ['config', '--get', 'core.sshCommand'] + const grant = await acquireGitAdmission({ + args: probeArgs, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) + let reportTerminated: () => void = () => {} + const terminated = new Promise((resolve) => { + reportTerminated = resolve + }) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { @@ -139,12 +152,15 @@ export async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise maxBuffer: DEFAULT_GIT_MAX_BUFFER, timeout: CORE_SSH_COMMAND_PROBE_TIMEOUT_MS, env: promptEnv, - signal: options.signal + signal: options.signal, + onChildTerminated: reportTerminated }) const payload = resolved.captured?.readStdout(String(stdout)) ?? String(stdout) configuredCommand = payload.trim() } catch { configuredCommand = '' + } finally { + void terminated.then(grant.release) } if (!configuredCommand) { diff --git a/src/main/git/command-runner/git-stream-admission-lifetime.test.ts b/src/main/git/command-runner/git-stream-admission-lifetime.test.ts new file mode 100644 index 00000000000..b4694fc7075 --- /dev/null +++ b/src/main/git/command-runner/git-stream-admission-lifetime.test.ts @@ -0,0 +1,79 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitSpawnMock, killSpawnedCommandTreeMock } = vi.hoisted(() => ({ + gitSpawnMock: vi.fn(), + killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./git-spawn', () => ({ gitSpawn: gitSpawnMock })) +vi.mock('./spawned-command-tree-kill', () => ({ + killSpawnedCommandTree: killSpawnedCommandTreeMock +})) + +import { gitStreamStdout } from './git-stream-stdout' +import { + GitAdmissionScheduler, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests +} from './git-subprocess-admission' + +function mockChild(): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = 1234 + child.kill = vi.fn(() => true) + child.stdin = null + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + return child as unknown as ChildProcess +} + +describe('git stream admission lifetime', () => { + beforeEach(() => { + gitSpawnMock.mockReset() + killSpawnedCommandTreeMock.mockClear() + _resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })) + }) + + afterEach(() => _resetGitAdmissionForTests()) + + it('retains the permit after maxBuffer settlement until close', async () => { + const child = mockChild() + gitSpawnMock.mockReturnValue(child) + const pending = gitStreamStdout(['status'], { + cwd: '/repo', + maxBuffer: 1, + onStdout: () => {} + }) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce()) + + child.stdout?.emit('data', Buffer.from('xx')) + await expect(pending).rejects.toThrow('maxBuffer') + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', null, 'SIGKILL') + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) + + it('retains the permit after abort settlement until close', async () => { + const child = mockChild() + const controller = new AbortController() + gitSpawnMock.mockReturnValue(child) + const pending = gitStreamStdout(['status'], { + cwd: '/repo', + signal: controller.signal, + onStdout: () => {} + }) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledOnce()) + + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(1) + + child.emit('close', null, 'SIGKILL') + await Promise.resolve() + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) + }) +}) diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index 7f33308d747..283459757ba 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -9,7 +9,11 @@ import { import { createAbortError } from './abort-error' import { killSpawnedCommandTree } from './spawned-command-tree-kill' import type { ResolvedCommand } from './wsl-command-resolution' -import { DEFAULT_GIT_MAX_BUFFER, type GitExecOptions } from './git-exec-options' +import { + DEFAULT_GIT_MAX_BUFFER, + type GitAdmissionTier, + type GitExecOptions +} from './git-exec-options' import { pendingWslDirectGitReadEnvironment, directWslGitExitCode, @@ -21,6 +25,8 @@ import { import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment' import { nonInteractiveGitEnv, untranslatedGitOutputEnv } from './git-process-env' import { gitSpawn } from './git-spawn' +import { acquireGitAdmission } from './git-subprocess-admission' +import { GitCommandTimeoutError, gitCommandTimeoutMs } from './git-command-timeout' /** Result of a streamed git command; `stoppedEarly` is true when onStdout asked to stop before the child exited. */ export type GitStreamResult = { stoppedEarly: boolean } @@ -33,6 +39,11 @@ export type GitStreamOptions = { signal?: AbortSignal /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number + /** Explicit wall-clock deadline; read commands default to the production backstop. */ + timeoutMs?: number + /** Overrides only the default read deadline in tests. */ + timeoutMsForTest?: number + admissionTier?: GitAdmissionTier /** * Called for each decoded stdout chunk. Return true to stop: the child is * killed and the promise resolves with stoppedEarly=true. @@ -52,7 +63,8 @@ export async function gitStreamStdout( options: GitStreamOptions ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER - return withGitSpan({ args, cwd: options.cwd }, async () => { + const timeoutMs = gitCommandTimeoutMs(args, options.timeoutMs, options.timeoutMsForTest) + return withGitSpan({ args, cwd: options.cwd }, async (span) => { if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { signal: options.signal @@ -63,7 +75,8 @@ export async function gitStreamStdout( ...(options.env ? { env: options.env } : {}), ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), ...(options.preferWslDirectGit ? { preferWslDirectGit: true } : {}), - ...(options.signal ? { signal: options.signal } : {}) + ...(options.signal ? { signal: options.signal } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) } const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, gitOptions) if (readEnvironmentReady) { @@ -79,6 +92,15 @@ export async function gitStreamStdout( gitOptions.env = await environmentReady } resolved = resolveGitCommand(args, gitOptions) + const grant = await acquireGitAdmission({ + args, + cwd: options.cwd, + wslDistro: options.wslDistro, + tier: options.admissionTier, + signal: options.signal + }) + span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs) + const terminationState: { current: Promise | null } = { current: null } const stream = (command: ResolvedCommand): Promise => new Promise((resolve, reject) => { if (options.signal?.aborted) { @@ -106,8 +128,25 @@ export async function gitStreamStdout( } else { child = gitSpawn(args, spawnOptions) } + let terminationReported = false + terminationState.current = new Promise((resolveTermination) => { + const reportTermination = (): void => { + if (terminationReported) { + return + } + terminationReported = true + resolveTermination() + } + child.once('close', reportTermination) + child.once('error', () => { + if (!child.pid) { + reportTermination() + } + }) + }) let settled = false + let timeoutTimer: ReturnType | null = null let stoppedEarly = false let stdoutBytes = 0 let stderr = '' @@ -117,6 +156,10 @@ export async function gitStreamStdout( const stderrDecoder = new StringDecoder('utf8') const cleanup = (): void => { + if (timeoutTimer) { + clearTimeout(timeoutTimer) + timeoutTimer = null + } child.stdout?.off('data', onStdoutData) child.stderr?.off('data', onStderrData) child.off('error', onError) @@ -194,32 +237,52 @@ export async function gitStreamStdout( finish(createAbortError()) } + function onTimeout(): void { + void killSpawnedCommandTree(child) + finish(new GitCommandTimeoutError(timeoutMs as number)) + } + child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) child.on('error', onError) child.on('close', onClose) options.signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined && timeoutMs > 0) { + timeoutTimer = setTimeout(onTimeout, timeoutMs) + } if (options.signal?.aborted) { onAbort() } }) try { - return await stream(resolved) - } catch (error) { - const stdoutBytes = - error && typeof error === 'object' ? (error as { stdoutBytes?: unknown }).stdoutBytes : null - if ( - stdoutBytes === 0 && - directWslGitExitCode(error, resolved) !== null && - !options.signal?.aborted - ) { - const wasMissing = invalidateMissingDirectWslGit(error, resolved) - resolved = resolveGitCommandWithoutProbe(args, gitOptions) - const result = await stream(resolved) - disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) - return result + try { + return await stream(resolved) + } catch (error) { + const stdoutBytes = + error && typeof error === 'object' + ? (error as { stdoutBytes?: unknown }).stdoutBytes + : null + if ( + stdoutBytes === 0 && + directWslGitExitCode(error, resolved) !== null && + !options.signal?.aborted + ) { + await terminationState.current + const wasMissing = invalidateMissingDirectWslGit(error, resolved) + resolved = resolveGitCommandWithoutProbe(args, gitOptions) + const result = await stream(resolved) + disableDirectWslGitAfterSuccessfulFallback(wasMissing, resolved) + return result + } + throw error + } + } finally { + const termination = terminationState.current + if (termination) { + void termination.then(grant.release) + } else { + grant.release() } - throw error } }) } diff --git a/src/main/git/command-runner/git-subprocess-admission.test.ts b/src/main/git/command-runner/git-subprocess-admission.test.ts new file mode 100644 index 00000000000..0e5bfa808ee --- /dev/null +++ b/src/main/git/command-runner/git-subprocess-admission.test.ts @@ -0,0 +1,558 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + GENERAL_CAP, + GitAdmissionScheduler, + MAX_GIT_CHILDREN, + NETWORK_CAP, + _gitAdmissionSnapshotForTests, + _resetGitAdmissionForTests, + acquireGitAdmission +} from './git-subprocess-admission' +import type { GitAdmissionEvent } from './git-admission-state' + +const local = (tier: 'interactive' | 'status' | 'background' = 'status') => ({ + args: ['status'], + cwd: '/repo', + tier +}) + +const schedulerWithOneSlot = (now: () => number = () => 0): GitAdmissionScheduler => + new GitAdmissionScheduler({ + generalCap: 1, + networkCap: 1, + generalHeadroom: 1, + networkHeadroom: 1, + routeCap: 1, + routeHeadroom: 1, + now + }) + +afterEach(() => { + delete process.env.ORCA_GIT_ADMISSION_DISABLED + _resetGitAdmissionForTests() +}) + +describe('GitAdmissionScheduler', () => { + it('pins the global base budgets and absolute maximum', () => { + expect(GENERAL_CAP).toBeGreaterThanOrEqual(2) + expect(GENERAL_CAP).toBeLessThanOrEqual(4) + expect(NETWORK_CAP).toBe(3) + expect(MAX_GIT_CHILDREN).toBe(10) + }) + + it('keeps base and headroom counters separate and grants interactive all-headroom', async () => { + const scheduler = schedulerWithOneSlot() + const base = await scheduler.acquire(local('background')) + const interactive = await scheduler.acquire({ + ...local('interactive'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + + expect(scheduler.snapshot().budgets).toMatchObject({ + general: { baseUsed: 1, headroomUsed: 1 }, + 'route:general:wsl:ubuntu': { baseUsed: 0, headroomUsed: 1 } + }) + + base.release() + expect(scheduler.snapshot().budgets.general).toEqual({ baseUsed: 0, headroomUsed: 1 }) + interactive.release() + expect(scheduler.snapshot().budgets.general).toEqual({ baseUsed: 0, headroomUsed: 0 }) + }) + + it('partitions network and general budgets globally and per route', async () => { + const scheduler = schedulerWithOneSlot() + const fetch = await scheduler.acquire({ + args: ['fetch'], + cwd: 'C:\\repo', + wslDistro: 'Ubuntu', + tier: 'background' + }) + const status = await scheduler.acquire({ + args: ['status'], + cwd: 'C:\\repo', + wslDistro: 'Ubuntu', + tier: 'status' + }) + + expect(scheduler.snapshot().budgets).toMatchObject({ + network: { baseUsed: 1, headroomUsed: 0 }, + general: { baseUsed: 1, headroomUsed: 0 }, + 'route:network:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 }, + 'route:general:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 } + }) + fetch.release() + status.release() + }) + + it('uses network and route headroom for an interactive push', async () => { + const scheduler = schedulerWithOneSlot() + const fetch = await scheduler.acquire({ + args: ['fetch'], + cwd: '\\\\server\\repo', + tier: 'background' + }) + const push = await scheduler.acquire({ + args: ['push'], + cwd: '\\\\server\\repo', + tier: 'interactive' + }) + + expect(scheduler.snapshot().budgets).toMatchObject({ + network: { baseUsed: 1, headroomUsed: 1 }, + 'route:network:unc:server': { baseUsed: 1, headroomUsed: 1 } + }) + fetch.release() + push.release() + }) + + it('admits same-route interactive work when general and route bases are full', async () => { + const scheduler = new GitAdmissionScheduler({ + generalCap: 2, + generalHeadroom: 2, + routeCap: 2, + routeHeadroom: 1 + }) + const request = { cwd: 'C:\\repo', wslDistro: 'Ubuntu' } + const background = await Promise.all([ + scheduler.acquire({ ...request, args: ['status'], tier: 'background' }), + scheduler.acquire({ ...request, args: ['status'], tier: 'background' }) + ]) + const interactive = await scheduler.acquire({ + ...request, + args: ['status'], + tier: 'interactive' + }) + + expect(scheduler.snapshot().budgets).toMatchObject({ + general: { baseUsed: 2, headroomUsed: 1 }, + 'route:general:wsl:ubuntu': { baseUsed: 2, headroomUsed: 1 } + }) + background.forEach((grant) => grant.release()) + interactive.release() + }) + + it('keeps status isolated from wedged route fetches and reserves push headroom', async () => { + const scheduler = new GitAdmissionScheduler() + const request = { cwd: 'C:\\repo', wslDistro: 'Ubuntu' } + const fetches = await Promise.all([ + scheduler.acquire({ ...request, args: ['fetch'], tier: 'background' }), + scheduler.acquire({ ...request, args: ['fetch'], tier: 'background' }) + ]) + const thirdFetch = await scheduler.acquire({ + args: ['fetch'], + cwd: '/other-repo', + tier: 'background' + }) + const status = await scheduler.acquire({ ...request, args: ['status'], tier: 'status' }) + const push = await scheduler.acquire({ ...request, args: ['push'], tier: 'interactive' }) + + expect(scheduler.snapshot().budgets).toMatchObject({ + network: { baseUsed: 3, headroomUsed: 1 }, + general: { baseUsed: 1, headroomUsed: 0 }, + 'route:network:wsl:ubuntu': { baseUsed: 2, headroomUsed: 1 }, + 'route:general:wsl:ubuntu': { baseUsed: 1, headroomUsed: 0 } + }) + fetches.forEach((grant) => grant.release()) + thirdFetch.release() + status.release() + push.release() + }) + + it('ages background work ahead of fresh base waiters without granting it headroom', async () => { + let now = 0 + const scheduler = schedulerWithOneSlot(() => now) + const running = await scheduler.acquire(local('status')) + const order: string[] = [] + const backgroundPromise = scheduler.acquire(local('background')).then((grant) => { + order.push('background') + return grant + }) + now = 30_000 + const headroom = await scheduler.acquire(local('interactive')) + const freshPromise = scheduler.acquire(local('interactive')).then((grant) => { + order.push('fresh') + return grant + }) + + running.release() + const background = await backgroundPromise + expect(order).toEqual(['background']) + expect(scheduler.snapshot().queued).toBe(1) + + background.release() + const fresh = await freshPromise + expect(order).toEqual(['background', 'fresh']) + headroom.release() + fresh.release() + }) + + it('keeps a route base slot free when fresh interactive work needs global headroom', async () => { + let now = 0 + const scheduler = schedulerWithOneSlot(() => now) + const running = await scheduler.acquire(local('status')) + const agedPromise = scheduler.acquire({ + ...local('background'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + now = 30_000 + const interactive = await scheduler.acquire({ + ...local('interactive'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + + expect(scheduler.snapshot().budgets['route:general:wsl:ubuntu']).toEqual({ + baseUsed: 0, + headroomUsed: 1 + }) + running.release() + const aged = await agedPromise + expect(scheduler.snapshot().budgets['route:general:wsl:ubuntu']).toEqual({ + baseUsed: 1, + headroomUsed: 1 + }) + aged.release() + interactive.release() + }) + + it('does not let a saturated route delay local-disk work below the general cap', async () => { + const scheduler = new GitAdmissionScheduler({ + generalCap: 3, + generalHeadroom: 1, + routeCap: 1, + routeHeadroom: 1 + }) + const routed = await scheduler.acquire({ + ...local('background'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + const queuedRoute = scheduler.acquire({ + ...local('background'), + cwd: 'C:\\other', + wslDistro: 'Ubuntu' + }) + const localDisk = await scheduler.acquire({ ...local('status'), cwd: 'C:\\local' }) + + expect(scheduler.snapshot().queued).toBe(1) + localDisk.release() + routed.release() + const secondRoute = await queuedRoute + secondRoute.release() + }) + + it('admits local-disk work beside two saturated-route children below the cap', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 4, routeCap: 2 }) + const routed = await Promise.all([ + scheduler.acquire({ + ...local('background'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }), + scheduler.acquire({ + ...local('background'), + cwd: 'C:\\other', + wslDistro: 'Ubuntu' + }) + ]) + const localDisk = await scheduler.acquire({ ...local('status'), cwd: 'C:\\local' }) + + expect(scheduler.snapshot()).toMatchObject({ + queued: 0, + budgets: { + general: { baseUsed: 3, headroomUsed: 0 }, + 'route:general:wsl:ubuntu': { baseUsed: 2, headroomUsed: 0 } + } + }) + routed.forEach((grant) => grant.release()) + localDisk.release() + }) + + it('removes an aborted queued waiter without changing occupancy', async () => { + const scheduler = schedulerWithOneSlot() + const running = await scheduler.acquire(local()) + const controller = new AbortController() + const queued = scheduler.acquire({ ...local(), signal: controller.signal }) + controller.abort() + + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + expect(scheduler.snapshot()).toMatchObject({ + queued: 0, + budgets: { general: { baseUsed: 1, headroomUsed: 0 } } + }) + running.release() + }) + + it('bounds canceled candidate storage while the only permit stays saturated', async () => { + const scheduler = schedulerWithOneSlot() + const running = await scheduler.acquire(local()) + const canceled: Promise[] = [] + + for (let index = 0; index < 4_000; index += 1) { + const controller = new AbortController() + const request = scheduler.acquire({ ...local(), signal: controller.signal }).then( + () => undefined, + (error) => expect(error).toMatchObject({ name: 'AbortError' }) + ) + controller.abort() + canceled.push(request) + } + await Promise.all(canceled) + + expect(scheduler.snapshot().queued).toBe(0) + expect(scheduler.snapshot().candidateCount).toBeLessThanOrEqual(64) + running.release() + expect(scheduler.snapshot().candidateCount).toBe(0) + }) + + it('does not decrement for an already-aborted signal with a free slot', async () => { + const scheduler = schedulerWithOneSlot() + const controller = new AbortController() + controller.abort() + + await expect( + scheduler.acquire({ ...local(), signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(scheduler.snapshot()).toEqual({ + queued: 0, + queuedWaiters: [], + candidateCount: 0, + budgets: {} + }) + }) + + it('returns a grant selected in the same tick when abort wins delivery', async () => { + const scheduler = schedulerWithOneSlot() + const running = await scheduler.acquire(local()) + const controller = new AbortController() + const queued = scheduler.acquire({ ...local(), signal: controller.signal }) + + running.release() + controller.abort() + + await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + expect(scheduler.snapshot()).toMatchObject({ + queued: 0, + budgets: { general: { baseUsed: 0, headroomUsed: 0 } } + }) + }) + + it('preserves FIFO after removing an aborted interactive waiter', async () => { + const scheduler = schedulerWithOneSlot() + const running = await scheduler.acquire(local()) + const firstController = new AbortController() + const first = scheduler.acquire({ ...local('interactive'), signal: firstController.signal }) + const second = scheduler.acquire(local('interactive')) + const third = scheduler.acquire(local('interactive')) + firstController.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + + running.release() + const secondGrant = await second + secondGrant.release() + const thirdGrant = await third + thirdGrant.release() + }) + + it('completes a background burst while interactive work uses reserved headroom', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 2, generalHeadroom: 1 }) + const order: number[] = [] + const background = Array.from({ length: 20 }, (_, index) => + scheduler.acquire(local('background')).then((grant) => { + order.push(index) + grant.release() + }) + ) + let interactiveRan = false + const interactive = scheduler.acquire(local('interactive')).then((grant) => { + interactiveRan = true + grant.release() + }) + + await Promise.all([...background, interactive]) + expect(interactiveRan).toBe(true) + expect(order).toEqual(Array.from({ length: 20 }, (_, index) => index)) + }) + + it('drains a large saturated FIFO burst without retaining settled waiters', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + const running = await scheduler.acquire(local('status')) + const count = 4_000 + const order: number[] = [] + const queued = Array.from({ length: count }, (_, index) => + scheduler.acquire(local('background')).then((grant) => { + order.push(index) + grant.release() + }) + ) + + expect(scheduler.snapshot().queued).toBe(count) + running.release() + await Promise.all(queued) + + expect(order).toEqual(Array.from({ length: count }, (_, index) => index)) + expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] }) + }) + + it('skips a 4k saturated-route lane without rescanning its blocked prefix', async () => { + const scheduler = new GitAdmissionScheduler({ + generalCap: 2, + generalHeadroom: 0, + routeCap: 1, + routeHeadroom: 0 + }) + const routedRunning = await scheduler.acquire({ + ...local('background'), + wslDistro: 'Ubuntu' + }) + const localRunning = await scheduler.acquire(local('background')) + let abortedReads = 0 + const signal = { + get aborted() { + abortedReads += 1 + return false + }, + addEventListener: () => {}, + removeEventListener: () => {} + } as unknown as AbortSignal + const count = 4_000 + const routed = Array.from({ length: count }, () => + scheduler + .acquire({ ...local('background'), wslDistro: 'Ubuntu', signal }) + .then((grant) => grant.release()) + ) + const localWork = Array.from({ length: count }, () => + scheduler.acquire({ ...local('background'), signal }).then((grant) => grant.release()) + ) + abortedReads = 0 + + localRunning.release() + await Promise.all(localWork) + expect(scheduler.snapshot().queued).toBe(count) + + routedRunning.release() + await Promise.all(routed) + expect(abortedReads).toBeLessThan(30_000) + expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] }) + }) + + it('selects one eligible route without scanning thousands of saturated routes', async () => { + const routeCount = 2_000 + const scheduler = new GitAdmissionScheduler({ + generalCap: routeCount, + generalHeadroom: 0, + routeCap: 1, + routeHeadroom: 0 + }) + const running = await Promise.all( + Array.from({ length: routeCount }, (_, index) => + scheduler.acquire({ ...local('background'), wslDistro: `distro-${index}` }) + ) + ) + let aborted = false + let abortedReads = 0 + const abortListeners = new Set<() => void>() + const signal = { + get aborted() { + abortedReads += 1 + return aborted + }, + addEventListener: (_event: string, listener: () => void) => abortListeners.add(listener), + removeEventListener: (_event: string, listener: () => void) => abortListeners.delete(listener) + } as unknown as AbortSignal + const queued = Array.from({ length: routeCount }, (_, index) => + scheduler + .acquire({ ...local('background'), wslDistro: `distro-${index}`, signal }) + .then((grant) => grant.release()) + ) + abortedReads = 0 + + running.at(-1)?.release() + await queued.at(-1) + + expect(abortedReads).toBeLessThanOrEqual(2) + expect(scheduler.snapshot().queued).toBe(routeCount - 1) + + aborted = true + for (const listener of abortListeners) { + listener() + } + await Promise.allSettled(queued.slice(0, -1)) + running.slice(0, -1).forEach((grant) => grant.release()) + expect(scheduler.snapshot()).toMatchObject({ queued: 0, queuedWaiters: [] }) + }) + + it('captures killswitch state in each release closure', async () => { + const scheduler = schedulerWithOneSlot() + _resetGitAdmissionForTests(scheduler) + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + const bypass = await acquireGitAdmission(local()) + delete process.env.ORCA_GIT_ADMISSION_DISABLED + bypass.release() + expect(_gitAdmissionSnapshotForTests().budgets.general).toBeUndefined() + + const admitted = await acquireGitAdmission(local()) + process.env.ORCA_GIT_ADMISSION_DISABLED = '1' + admitted.release() + expect(_gitAdmissionSnapshotForTests().budgets.general).toEqual({ + baseUsed: 0, + headroomUsed: 0 + }) + }) + + it('publishes monotonic grant and release events with cap metadata', async () => { + const events: GitAdmissionEvent[] = [] + const scheduler = new GitAdmissionScheduler({ + generalCap: 1, + generalHeadroom: 1, + routeCap: 1, + routeHeadroom: 1, + onAdmissionEvent: (event) => events.push(event) + }) + const background = await scheduler.acquire({ + ...local('background'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + const interactive = await scheduler.acquire({ + ...local('interactive'), + cwd: 'C:\\repo', + wslDistro: 'Ubuntu' + }) + background.release() + interactive.release() + + expect(events.map(({ sequence, phase, slotKind }) => [sequence, phase, slotKind])).toEqual([ + [0, 'grant', 'base'], + [1, 'grant', 'headroom'], + [2, 'release', 'base'], + [3, 'release', 'headroom'] + ]) + expect(events[1]).toMatchObject({ + tier: 'interactive', + admissionClass: 'general', + route: 'wsl:ubuntu', + queued: 0 + }) + expect(events[1]?.budgets).toEqual( + expect.arrayContaining([ + { + key: 'general', + baseCapacity: 1, + headroomCapacity: 1, + baseUsed: 1, + headroomUsed: 1 + }, + { + key: 'route:general:wsl:ubuntu', + baseCapacity: 1, + headroomCapacity: 1, + baseUsed: 1, + headroomUsed: 1 + } + ]) + ) + }) +}) diff --git a/src/main/git/command-runner/git-subprocess-admission.ts b/src/main/git/command-runner/git-subprocess-admission.ts new file mode 100644 index 00000000000..b23cbda170b --- /dev/null +++ b/src/main/git/command-runner/git-subprocess-admission.ts @@ -0,0 +1,325 @@ +import { uncRouteKey } from '../../providers/working-directory-validation' +import { classifyGitCommand } from '../wsl-direct-git-read-commands' +import { createAbortError } from './abort-error' +import { GitAdmissionWaiterQueue } from './git-admission-waiter-queue' +import { + ADMISSION_TIER_VALUE, + AdmissionEventPublisher, + DEFAULT_ADMISSION_SCHEDULER_CONFIG, + type AdmissionBudget, + type AdmissionClass, + type AdmissionSchedulerConfig, + type AdmissionSlotKind, + type AdmissionWaiter, + type GitAdmissionGrant, + type GitAdmissionRequest +} from './git-admission-state' + +export type { + GitAdmissionEvent, + GitAdmissionGrant, + GitAdmissionRequest +} from './git-admission-state' +export { + GENERAL_CAP, + GENERAL_HEADROOM, + GIT_ADMISSION_AGING_MS, + MAX_GIT_CHILDREN, + NETWORK_CAP, + NETWORK_HEADROOM, + ROUTE_CAP, + ROUTE_HEADROOM +} from './git-admission-state' + +function commandClass(args: readonly string[]): AdmissionClass { + return classifyGitCommand(args) === 'network' ? 'network' : 'general' +} + +function routeKey(request: GitAdmissionRequest): string | null { + const distro = request.wslDistro?.trim().toLowerCase() + return distro ? `wsl:${distro}` : uncRouteKey(request.cwd) +} + +export class GitAdmissionScheduler { + private readonly config: AdmissionSchedulerConfig + private readonly budgets = new Map() + private readonly waiters = new GitAdmissionWaiterQueue() + private nextWaiterId = 0 + private readonly eventPublisher: AdmissionEventPublisher + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_ADMISSION_SCHEDULER_CONFIG, ...config } + this.eventPublisher = new AdmissionEventPublisher(this.config.onAdmissionEvent) + } + + acquire(request: GitAdmissionRequest): Promise { + if (request.signal?.aborted) { + return Promise.reject(createAbortError()) + } + const enqueuedAt = this.config.now() + const { admissionClass, route, budgetKeys } = this.resolveBudgets(request) + return new Promise((resolve, reject) => { + const waiter: AdmissionWaiter = { + id: this.nextWaiterId++, + args: request.args, + tier: request.tier ?? 'status', + admissionClass, + route, + enqueuedAt, + budgetKeys, + signal: request.signal, + state: 'queued', + resolve, + reject, + onAbort: () => this.abort(waiter) + } + this.waiters.enqueue(waiter) + this.refreshRouteEligibility(admissionClass, route) + request.signal?.addEventListener('abort', waiter.onAbort, { once: true }) + if (request.signal?.aborted) { + this.abort(waiter) + return + } + // Adding a blocked waiter cannot make an older waiter runnable. Avoid a + // queue scan for every arrival while the fixed-size budget is saturated. + if (this.slotKindFor(waiter)) { + this.drain(admissionClass) + } + }) + } + + snapshot(): { + queued: number + queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[] + budgets: Record + candidateCount: number + } { + const queuedWaiters = this.waiters.snapshot() + return { + queued: this.waiters.count, + queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })), + candidateCount: this.waiters.candidateCountForTests, + budgets: Object.fromEntries( + [...this.budgets].map(([key, budget]) => [ + key, + { baseUsed: budget.baseUsed, headroomUsed: budget.headroomUsed } + ]) + ) + } + } + + private resolveBudgets(request: GitAdmissionRequest): { + admissionClass: AdmissionClass + route: string | null + budgetKeys: readonly string[] + } { + const admissionClass = commandClass(request.args) + const route = routeKey(request) + const keys: string[] = [admissionClass] + if (route) { + keys.push(`route:${admissionClass}:${route}`) + } + for (const key of keys) { + this.ensureBudget(key) + } + return { admissionClass, route, budgetKeys: keys } + } + + private ensureBudget(key: string): AdmissionBudget { + let budget = this.budgets.get(key) + if (budget) { + return budget + } + const isRoute = key.startsWith('route:') + const isNetwork = key === 'network' + budget = { + baseCapacity: isRoute + ? this.config.routeCap + : isNetwork + ? this.config.networkCap + : this.config.generalCap, + headroomCapacity: isRoute + ? this.config.routeHeadroom + : isNetwork + ? this.config.networkHeadroom + : this.config.generalHeadroom, + baseUsed: 0, + headroomUsed: 0 + } + this.budgets.set(key, budget) + return budget + } + + private effectiveTier(waiter: AdmissionWaiter, now: number): number { + const promotions = Math.floor((now - waiter.enqueuedAt) / this.config.agingMs) + return Math.max(0, ADMISSION_TIER_VALUE[waiter.tier] - promotions) + } + + private fits(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind): boolean { + return waiter.budgetKeys.every((key) => { + const budget = this.ensureBudget(key) + return slotKind === 'base' + ? budget.baseUsed < budget.baseCapacity + : budget.headroomUsed < budget.headroomCapacity + }) + } + + private slotKindFor(waiter: AdmissionWaiter): AdmissionSlotKind | null { + return this.fits(waiter, 'base') + ? 'base' + : waiter.tier === 'interactive' && this.fits(waiter, 'headroom') + ? 'headroom' + : null + } + + private drain(admissionClass: AdmissionClass): void { + while (true) { + const now = this.config.now() + const globalBudget = this.ensureBudget(admissionClass) + const selected = this.waiters.nextFitting( + admissionClass, + (waiter) => this.effectiveTier(waiter, now), + globalBudget.baseUsed < globalBudget.baseCapacity, + globalBudget.headroomUsed < globalBudget.headroomCapacity, + (waiter) => this.abort(waiter) + ) + if (!selected) { + return + } + this.grant(selected.waiter, selected.slotKind, now) + } + } + + private grant(waiter: AdmissionWaiter, slotKind: AdmissionSlotKind, now: number): void { + waiter.state = 'granted' + waiter.slotKind = slotKind + for (const key of waiter.budgetKeys) { + const budget = this.ensureBudget(key) + if (slotKind === 'base') { + budget.baseUsed += 1 + } else { + budget.headroomUsed += 1 + } + } + this.refreshRouteEligibility(waiter.admissionClass, waiter.route) + this.waiters.dequeue(waiter) + const queueWaitMs = Math.max(0, now - waiter.enqueuedAt) + this.publishEvent(waiter, slotKind, 'grant', queueWaitMs) + queueMicrotask(() => { + if (waiter.state !== 'granted') { + return + } + waiter.state = 'settled' + waiter.signal?.removeEventListener('abort', waiter.onAbort) + waiter.resolve({ + queueWaitMs, + release: this.releaseOnce(waiter, slotKind, queueWaitMs) + }) + }) + } + + private releaseOnce( + waiter: AdmissionWaiter, + slotKind: AdmissionSlotKind, + queueWaitMs: number + ): () => void { + let released = false + return () => { + if (released) { + return + } + released = true + for (const key of waiter.budgetKeys) { + const budget = this.ensureBudget(key) + if (slotKind === 'base') { + budget.baseUsed -= 1 + } else { + budget.headroomUsed -= 1 + } + } + this.refreshRouteEligibility(waiter.admissionClass, waiter.route) + this.publishEvent(waiter, slotKind, 'release', queueWaitMs) + this.pruneRouteBudgets(waiter.budgetKeys) + this.drain(waiter.admissionClass) + } + } + + private abort(waiter: AdmissionWaiter): void { + if (waiter.state === 'settled') { + return + } + if (waiter.state === 'granted' && waiter.slotKind) { + this.releaseOnce( + waiter, + waiter.slotKind, + Math.max(0, this.config.now() - waiter.enqueuedAt) + )() + } + const wasQueued = waiter.state === 'queued' + waiter.state = 'settled' + waiter.signal?.removeEventListener('abort', waiter.onAbort) + if (wasQueued) { + this.waiters.dequeue(waiter) + this.pruneRouteBudgets(waiter.budgetKeys) + } + waiter.reject(createAbortError()) + } + + private publishEvent( + waiter: AdmissionWaiter, + slotKind: AdmissionSlotKind, + phase: 'grant' | 'release', + queueWaitMs: number + ): void { + this.eventPublisher.publish({ + phase, + waiter, + slotKind, + queueWaitMs, + queued: this.waiters.count, + budgets: this.budgets + }) + } + + private pruneRouteBudgets(keys: readonly string[]): void { + for (const key of keys) { + const budget = this.budgets.get(key) + if ( + budget && + key.startsWith('route:') && + budget.baseUsed === 0 && + budget.headroomUsed === 0 && + !this.waiters.hasBudget(key) + ) { + this.budgets.delete(key) + } + } + } + + private refreshRouteEligibility(admissionClass: AdmissionClass, route: string | null): void { + const budget = route ? this.ensureBudget(`route:${admissionClass}:${route}`) : null + this.waiters.updateRouteEligibility( + admissionClass, + route, + !budget || budget.baseUsed < budget.baseCapacity, + !budget || budget.headroomUsed < budget.headroomCapacity + ) + } +} + +let scheduler = new GitAdmissionScheduler() + +export function acquireGitAdmission(request: GitAdmissionRequest): Promise { + if (process.env.ORCA_GIT_ADMISSION_DISABLED === '1') { + return Promise.resolve({ queueWaitMs: 0, release: () => {} }) + } + return scheduler.acquire(request) +} + +export function _resetGitAdmissionForTests(replacement = new GitAdmissionScheduler()): void { + scheduler = replacement +} + +export function _gitAdmissionSnapshotForTests(): ReturnType { + return scheduler.snapshot() +} diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index edc3bd4b30c..b1ed5ac885c 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -1,16 +1,20 @@ +import type { GitAdmissionTier } from './command-runner/git-exec-options' + export type GitRuntimeOptions = { wslDistro?: string signal?: AbortSignal + admissionTier?: GitAdmissionTier } export function gitOptionsForWorktree( cwd: string, options: GitRuntimeOptions = {} -): { cwd: string; wslDistro?: string; signal?: AbortSignal } { +): { cwd: string; wslDistro?: string; signal?: AbortSignal; admissionTier?: GitAdmissionTier } { return { cwd, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.signal ? { signal: options.signal } : {}) + ...(options.signal ? { signal: options.signal } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) } } @@ -27,6 +31,7 @@ export function gitReadOptionsForWorktree( cwd: string wslDistro?: string signal?: AbortSignal + admissionTier?: GitAdmissionTier preferWslDirectGit: true } { return { ...gitOptionsForWorktree(cwd, options), preferWslDirectGit: true } diff --git a/src/main/git/push-target-validation.ts b/src/main/git/push-target-validation.ts index 8e3375c100f..055eab8537c 100644 --- a/src/main/git/push-target-validation.ts +++ b/src/main/git/push-target-validation.ts @@ -1,10 +1,9 @@ import type { GitPushTarget } from '../../shared/worktree/types' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from './runner' +import type { GitExecOptions as GitCommandExecOptions } from './command-runner/git-exec-options' -type GitExecOptions = { - wslDistro?: string -} +type GitExecOptions = Pick export async function validateGitPushTarget( repoPath: string, diff --git a/src/main/git/remote-ref-probe-cache.ts b/src/main/git/remote-ref-probe-cache.ts index 3bc2f676fbb..8f8255c1046 100644 --- a/src/main/git/remote-ref-probe-cache.ts +++ b/src/main/git/remote-ref-probe-cache.ts @@ -2,6 +2,7 @@ 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' +import type { GitAdmissionTier } from './command-runner/git-exec-options' /** * The "is this repo mine?" probe every forge integration runs: read the remote's @@ -25,6 +26,7 @@ type CachedRepoRef = { value: Ref | null; expiresAt: number } export type RemoteRefLocalGitOptions = { wslDistro?: string + admissionTier?: GitAdmissionTier } export type RemoteRefProbeCache = { @@ -79,7 +81,8 @@ export function createRemoteRefProbeCache( { repoPath, connectionId, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }, remoteName ) diff --git a/src/main/git/remote-url-probe.ts b/src/main/git/remote-url-probe.ts index d237bdd98fa..02bc2a17fa7 100644 --- a/src/main/git/remote-url-probe.ts +++ b/src/main/git/remote-url-probe.ts @@ -4,6 +4,7 @@ import { } from '../providers/ssh-git-dispatch' import { gitExecFileAsync } from './runner' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' +import type { GitAdmissionTier } from './command-runner/git-exec-options' /** * The `git remote get-url` probe every forge integration runs to decide whether @@ -23,6 +24,7 @@ export type RemoteUrlProbeContext = { repoPath: string connectionId?: string | null wslDistro?: string + admissionTier?: GitAdmissionTier } /** Reads a remote URL, or null when the repo's SSH runtime is not connected. */ @@ -43,7 +45,8 @@ export async function readRemoteUrl( const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: context.repoPath, timeout: REMOTE_URL_PROBE_TIMEOUT_MS, - ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}), + ...(context.admissionTier ? { admissionTier: context.admissionTier } : {}) }) return stdout } diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 0e71d2cc57a..1974c4e2384 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -22,6 +22,12 @@ import { translateWslOutputPaths, wslAwareSpawn } from './runner' +import { + GitAdmissionScheduler, + _resetGitAdmissionForTests +} from './command-runner/git-subprocess-admission' + +afterEach(() => _resetGitAdmissionForTests()) type MockChildProcess = EventEmitter & { stdout: EventEmitter @@ -213,6 +219,7 @@ describe('runner execFile timeout handling', () => { timeout: 1000 }) const rejection = expect(promise).rejects.toThrow('git timed out.') + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(1000) await rejection @@ -249,6 +256,7 @@ describe('runner execFile timeout handling', () => { } ) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) controller.abort() child.emit('close', 0, null) await vi.advanceTimersByTimeAsync(1_999) @@ -412,6 +420,36 @@ describe('runner execFile timeout handling', () => { ) }) + it('admits the core.sshCommand probe before spawning it', async () => { + const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 }) + _resetGitAdmissionForTests(scheduler) + const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo', tier: 'status' }) + const calls: string[][] = [] + execFileMock.mockImplementation((_cmd, args, _opts, cb) => { + const child = createMockChildProcess(1234 + calls.length) + calls.push(args) + cb(null, '', '') + queueMicrotask(() => child.emit('close', 0, null)) + return child + }) + + const pending = gitExecFileAsync(['fetch', '--no-write-fetch-head', 'origin'], { + cwd: '/repo', + env: {}, + useConfiguredSshCommandForNetwork: true + }) + await Promise.resolve() + expect(execFileMock).not.toHaveBeenCalled() + + blocker.release() + await pending + + expect(calls).toEqual([ + ['config', '--get', 'core.sshCommand'], + ['fetch', '--no-write-fetch-head', 'origin'] + ]) + }) + it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => { const child = createMockChildProcess(1234) let capturedEnv: NodeJS.ProcessEnv | undefined @@ -717,6 +755,7 @@ describe('gitStreamStdout', () => { chunks.push(chunk) } }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) child.stdout.emit('data', Buffer.from('? a.txt\n')) child.stdout.emit('data', Buffer.from('? b.txt\n')) child.emit('close', 0) @@ -739,11 +778,13 @@ describe('gitStreamStdout', () => { return true } }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) child.stdout.emit('data', Buffer.from('? a.txt\n')) await expect(promise).resolves.toEqual({ stoppedEarly: true }) expect(child.kill).toHaveBeenCalled() expect(calls).toBe(1) + child.emit('close', null, 'SIGTERM') }) it('rejects when stdout exceeds the maxBuffer backstop', async () => { @@ -756,10 +797,12 @@ describe('gitStreamStdout', () => { onStdout: () => {} }) const rejection = expect(promise).rejects.toThrow('git stdout exceeded maxBuffer.') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) child.stdout.emit('data', Buffer.from('way too much')) await rejection expect(child.kill).toHaveBeenCalled() + child.emit('close', null, 'SIGTERM') }) it('rejects on a non-zero exit with stderr context', async () => { @@ -768,6 +811,7 @@ describe('gitStreamStdout', () => { const promise = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} }) const rejection = expect(promise).rejects.toThrow('git exited with 128') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) child.stderr.emit('data', Buffer.from('fatal: not a git repository')) child.emit('close', 128) @@ -785,10 +829,12 @@ describe('gitStreamStdout', () => { } }) const rejection = expect(promise).rejects.toThrow('parser blew up') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) child.stdout.emit('data', Buffer.from('? a.txt\n')) await rejection expect(child.kill).toHaveBeenCalled() + child.emit('close', null, 'SIGTERM') }) it('handles a late spawn error after cancellation', async () => { @@ -801,6 +847,7 @@ describe('gitStreamStdout', () => { signal: controller.signal, onStdout: () => {} }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()) controller.abort() await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) diff --git a/src/main/git/runner-windows-host-environment.test.ts b/src/main/git/runner-windows-host-environment.test.ts index e441b023ff4..367d830b767 100644 --- a/src/main/git/runner-windows-host-environment.test.ts +++ b/src/main/git/runner-windows-host-environment.test.ts @@ -20,6 +20,7 @@ import { gitSpawnAfterWindowsEnvironmentReady, gitStreamStdout } from './runner' +import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' type MockChildProcess = EventEmitter & { stdout: EventEmitter @@ -64,6 +65,7 @@ describe('Windows host Git environment readiness', () => { }) afterEach(() => { + _resetGitAdmissionForTests() configureWindowsHostGitEnvironmentReadiness(null) if (originalPath === undefined) { delete process.env.Path diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index 9cc43223c76..284cda55718 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -19,6 +19,11 @@ vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution' import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' +import { + GitAdmissionScheduler, + _resetGitAdmissionForTests, + type GitAdmissionEvent +} from './command-runner/git-subprocess-admission' import { disableWslGitReadEnvironment, getWslGitReadEnvironment, @@ -33,6 +38,8 @@ import { type WslLinkedWorktreeRoutingFileSystem } from './wsl-linked-worktree-git-routing' +afterEach(() => _resetGitAdmissionForTests()) + const DISTRO = 'Ubuntu' const LOGIN_ENVIRONMENT = { gitPath: '/home/user/bin/git', @@ -493,36 +500,53 @@ describe('WSL direct Git reads', () => { }) }) - it('invalidates a missing direct executable and retries through the login shell', async () => { + it('waits for direct Git to close before retrying through the login shell', async () => { await withPlatform('win32', async () => { + const admissionEvents: GitAdmissionEvent[] = [] + _resetGitAdmissionForTests( + new GitAdmissionScheduler({ onAdmissionEvent: (event) => admissionEvents.push(event) }) + ) seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) + let directChild!: ReturnType execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + directChild = child + queueMicrotask(() => { callback?.( Object.assign(new Error('exit 127'), { code: 127 }), '', '/usr/bin/env: No such file or directory' ) - ) + }) return child }) execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => callback?.(null, 'ok', '')) + queueMicrotask(() => { + child.emit('close', 0, null) + callback?.(null, 'ok', '') + }) return child }) - await expect( - gitExecFileAsync(['status', '--short'], { - cwd: String.raw`C:\repo`, - preferWslDirectGit: true, - wslDistro: DISTRO - }) - ).resolves.toEqual({ stdout: 'ok', stderr: '' }) + const result = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + preferWslDirectGit: true, + wslDistro: DISTRO + }) + + await vi.waitFor(() => expect(directChild).toBeDefined()) + await Promise.resolve() + expect(execFileMock).toHaveBeenCalledTimes(1) + directChild.emit('close', 127, null) + await expect(result).resolves.toEqual({ stdout: 'ok', stderr: '' }) expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec') expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc']) + expect(admissionEvents.map(({ phase, waiterId }) => [phase, waiterId])).toEqual([ + ['grant', 0], + ['release', 0] + ]) }) }) @@ -531,14 +555,18 @@ describe('WSL direct Git reads', () => { seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT) execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed') - ) + child.emit('close', 128, null) + }) return child }) execFileMock.mockImplementation((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => callback?.(null, 'ok', '')) + queueMicrotask(() => { + callback?.(null, 'ok', '') + child.emit('close', 0, null) + }) return child }) const options = { @@ -562,21 +590,26 @@ describe('WSL direct Git reads', () => { execFileMock .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - ) + child.emit('close', 128, null) + }) return child }) .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => + queueMicrotask(() => { callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref') - ) + child.emit('close', 128, null) + }) return child }) .mockImplementationOnce((_command, _args, _options, callback) => { const child = createMockChild() - queueMicrotask(() => callback?.(null, 'ok', '')) + queueMicrotask(() => { + callback?.(null, 'ok', '') + child.emit('close', 0, null) + }) return child }) const options = { diff --git a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts index 553b07d6b38..527c5e9b99f 100644 --- a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts +++ b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts @@ -21,6 +21,9 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { gitExecFileAsync } from './runner' +import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' + +afterEach(() => _resetGitAdmissionForTests()) import { resetWslLinkedWorktreeGitRoutingForTests, WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS diff --git a/src/main/git/runner-wsl-login-shell-capture.test.ts b/src/main/git/runner-wsl-login-shell-capture.test.ts index 222d64e5773..cbf7dfdd067 100644 --- a/src/main/git/runner-wsl-login-shell-capture.test.ts +++ b/src/main/git/runner-wsl-login-shell-capture.test.ts @@ -18,6 +18,9 @@ vi.mock('../observability/instrumentation', () => ({ vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) import { gitExecFileAsync, gitExecFileAsyncBuffer } from './runner' +import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' + +afterEach(() => _resetGitAdmissionForTests()) import { resetWslGitReadEnvironmentForTests } from './wsl-git-read-environment' const DISTRO = 'Ubuntu' diff --git a/src/main/git/runner-wsl-read-routing.test.ts b/src/main/git/runner-wsl-read-routing.test.ts index 21c9bc45e38..9bb957c48ef 100644 --- a/src/main/git/runner-wsl-read-routing.test.ts +++ b/src/main/git/runner-wsl-read-routing.test.ts @@ -18,6 +18,9 @@ vi.mock('../observability/instrumentation', () => ({ vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) import { gitExecFileAsync } from './runner' +import { _resetGitAdmissionForTests } from './command-runner/git-subprocess-admission' + +afterEach(() => _resetGitAdmissionForTests()) import { resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 7a07a3302b8..4db317f1287 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -17,6 +17,7 @@ export { configureWindowsHostGitEnvironmentReadiness } from './command-runner/windows-host-git-environment' export { DEFAULT_GIT_MAX_BUFFER } from './command-runner/git-exec-options' +export { GitCommandTimeoutError } from './command-runner/git-command-timeout' export { appendGitConfigEnv, gitOptionalLocksDisabledEnv, @@ -32,7 +33,11 @@ export { } from './command-runner/git-exec-file' export { commandExecFileAsync } from './command-runner/command-exec-file' export { gitStreamStdout, type GitStreamResult } from './command-runner/git-stream-stdout' -export { gitSpawn, gitSpawnAfterWindowsEnvironmentReady } from './command-runner/git-spawn' +export { + gitSpawn, + gitSpawnAfterWindowsEnvironmentReady, + withGitAdmission +} from './command-runner/git-spawn' export { isTransientGhError } from './command-runner/gh-retry-policy' export { applyGhHostToArgs } from './command-runner/gh-host-args' export { ghExecFileAsync } from './command-runner/gh-exec-file' diff --git a/src/main/git/source-control/get-status-options.ts b/src/main/git/source-control/get-status-options.ts index 1b69cc3eae6..d8f4099c77a 100644 --- a/src/main/git/source-control/get-status-options.ts +++ b/src/main/git/source-control/get-status-options.ts @@ -2,6 +2,7 @@ import type { GitRuntimeOptions } from '../git-runtime-options' export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean + includeLineStats?: boolean reuseLineStats?: boolean /** Merge-base OID the caller wants the branch line total measured against; * omitted means the chip is hidden, so no ranged diff runs at all. */ diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index 7893cabde15..ae1fbbec8df 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -53,7 +53,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri return stableInFlightKey([ worktreePath, options.wslDistro ?? '', + options.admissionTier ?? 'status', options.includeIgnored === true, + options.includeLineStats !== false, options.reuseLineStats === true, // Why: the result carries a total only for callers who asked, and only for // this fork point, so a shared lease must never serve one to the other. @@ -102,7 +104,8 @@ async function runGetStatus( options: GetStatusOptions = {} ): Promise { const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options) - const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) + const lineStatsWriteToken = + options.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false // Why: a bad limit (negative/fractional/NaN) breaks early-stop; require a valid non-negative int (0 disables the cap). @@ -132,6 +135,7 @@ async function runGetStatus( const result = await gitStreamStdout(statusArgs, { cwd: worktreePath, wslDistro: options.wslDistro, + admissionTier: options.admissionTier, preferWslDirectGit: true, // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. env: gitOptionalLocksDisabledEnv(), @@ -209,7 +213,7 @@ async function runGetStatus( // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. let branchLineTotal: GitBranchLineTotal | undefined - if (!didHitLimit) { + if (!didHitLimit && lineStatsWriteToken !== null) { const branchLineTotalInput = createBranchLineTotalInput( worktreePath, entries, @@ -227,7 +231,7 @@ async function runGetStatus( ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) }) branchLineTotal = lineStats.branchLineTotal - } else { + } else if (lineStatsWriteToken !== null) { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } diff --git a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts index fbae736e77f..6404e5deca1 100644 --- a/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts +++ b/src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts @@ -35,6 +35,9 @@ vi.mock('../../../shared/git-discard-path-safety', () => ({ import { bulkDiscardChanges } from './discard-changes' import { resetWslGitReadEnvironmentForTests } from '../wsl-git-read-environment' +import { _resetGitAdmissionForTests } from '../command-runner/git-subprocess-admission' + +afterEach(() => _resetGitAdmissionForTests()) const DISTRO = 'Ubuntu-24.04' const WSL_WORKTREE = `\\\\wsl$\\${DISTRO}\\home\\emilio\\projects\\orca` diff --git a/src/main/git/status-read-coalescing.test.ts b/src/main/git/status-read-coalescing.test.ts index 6eacba1fb69..e0170f8d6a5 100644 --- a/src/main/git/status-read-coalescing.test.ts +++ b/src/main/git/status-read-coalescing.test.ts @@ -71,15 +71,19 @@ describe('getStatus', () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) }) - it('opts status reads into direct WSL Git without changing mutation options', async () => { + it('preserves status admission through the direct WSL stream without changing mutation options', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) - await getStatus('/repo', { wslDistro: 'Ubuntu' }) + await getStatus('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' }) await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' }) expect(gitStreamOptionsMock).toHaveBeenCalledWith( - expect.objectContaining({ preferWslDirectGit: true, wslDistro: 'Ubuntu' }) + expect.objectContaining({ + admissionTier: 'interactive', + preferWslDirectGit: true, + wslDistro: 'Ubuntu' + }) ) const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) => (args as string[]).includes('add') @@ -290,16 +294,18 @@ describe('getStatus', () => { getStatus('/other-repo'), getStatus('/repo', { wslDistro: 'Ubuntu' }), getStatus('/repo', { includeIgnored: true }), + getStatus('/repo', { includeLineStats: false }), getStatus('/repo', { reuseLineStats: true }), getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }), getStatus('/repo', { limit: 1 }), - getStatus('/repo', { sharedLinkPaths: ['node_modules'] }) + getStatus('/repo', { sharedLinkPaths: ['node_modules'] }), + getStatus('/repo', { admissionTier: 'interactive' }) ] - await vi.waitFor(() => expect(statusCommandCalls).toBe(8)) + await vi.waitFor(() => expect(statusCommandCalls).toBe(10)) releases.splice(0).forEach((release) => release()) await Promise.all(reads) - expect(statusCommandCalls).toBe(8) + expect(statusCommandCalls).toBe(10) }) it('clears in-flight status reads when a mutation runs', async () => { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 9663becb622..b8ab930cc86 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -368,6 +368,35 @@ describe('getStatus', () => { ]) }) + it('omits line stats without overwriting the reusable line-stats cache', async () => { + readFileMock.mockResolvedValue('gitdir: /stats-repo/.git/worktrees/feature\n') + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-stats\n' + + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const withStats = await getStatus('/stats-repo') + const withoutStats = await getStatus('/stats-repo', { includeLineStats: false }) + const reused = await getStatus('/stats-repo', { reuseLineStats: true }) + + expect(withoutStats.entries).toEqual( + withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry) + ) + expect(reused.entries).toEqual(withStats.entries) + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(1) + }) + it('reuses unchanged line stats only when the safety hint is present', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') gitExecFileAsyncMock.mockImplementation((args: string[]) => { diff --git a/src/main/git/upstream.ts b/src/main/git/upstream.ts index 81f193d4f6c..18b2756a133 100644 --- a/src/main/git/upstream.ts +++ b/src/main/git/upstream.ts @@ -7,9 +7,11 @@ import { getPublishTargetStatus } from '../../shared/git-publish-target-status' import { gitExecFileAsync } from './runner' import { validateGitPushTarget } from './push-target-validation' import { nativeAndWslGitUpstreamStatusReadOwner } from './git-upstream-status-read-owner' +import type { GitAdmissionTier } from './command-runner/git-exec-options' type GitExecOptions = { wslDistro?: string + admissionTier?: GitAdmissionTier } export function invalidateGitUpstreamStatusReads(): void { @@ -19,8 +21,12 @@ export function invalidateGitUpstreamStatusReads(): void { function gitExecOptions( cwd: string, options: GitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +): { cwd: string; wslDistro?: string; admissionTier?: GitAdmissionTier } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) + } } async function getBehindCommitsArePatchEquivalent( diff --git a/src/main/git/wsl-direct-git-read-commands.test.ts b/src/main/git/wsl-direct-git-read-commands.test.ts index ed8c2458f69..aa0fc9711fe 100644 --- a/src/main/git/wsl-direct-git-read-commands.test.ts +++ b/src/main/git/wsl-direct-git-read-commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' +import { classifyGitCommand, isWslDirectGitReadCommand } from './wsl-direct-git-read-commands' describe('isWslDirectGitReadCommand', () => { it.each([ @@ -49,6 +49,8 @@ describe('isWslDirectGitReadCommand', () => { [['symbolic-ref', 'HEAD', 'refs/heads/main']], [['worktree', 'add', '/tmp/wt']], [['branch', '-D', 'feature']], + [['branch', '-r', '-d', 'origin/feature']], + [['branch', '-rd', 'origin/feature']], [['branch', 'newbranch']], [[]] ])('keeps %j on the login shell', (args) => { @@ -89,3 +91,72 @@ describe('isWslDirectGitReadCommand', () => { expect(isWslDirectGitReadCommand(args)).toBe(false) }) }) + +describe('classifyGitCommand', () => { + it.each([ + [['fetch', '--all'], 'network'], + [['pull', '--rebase'], 'network'], + [['push', 'origin', 'main'], 'network'], + [['clone', 'https://example.com/repo.git'], 'network'], + [['ls-remote', '--heads', 'origin'], 'network'], + [['submodule', 'update', '--init'], 'network'], + [['remote', 'update', '--prune'], 'network'], + [['-c', 'maintenance.auto=false', 'fetch', '--all'], 'network'], + [['-C', '/repo', 'status', '--porcelain=v2'], 'read'], + [['status', '--porcelain=v2'], 'read'], + [['branch', '--show-current'], 'read'], + [['branch', '-r', '-d', 'origin/feature'], 'other'], + [['branch', '-rd', 'origin/feature'], 'other'], + [['remote', 'get-url', 'origin'], 'read'], + [['config', '--get-regexp', String.raw`^remote\.`], 'read'], + [['show', '--end-of-options', 'HEAD:file'], 'read'], + [['blame', '--', 'file'], 'read'], + [['submodule', 'status'], 'read'], + [['submodule', 'sync'], 'other'], + [['submodule', 'foreach', 'status'], 'other'], + [['remote', 'show', 'origin'], 'other'], + [['remote', 'add', 'origin', 'url'], 'other'], + [['checkout', 'main'], 'other'], + [['made-up-command'], 'other'], + [[], 'other'], + // Unparsed space-separated global values remain fail-safe. + [['--git-dir', '/repo/.git', 'log'], 'other'], + [['--work-tree', '/repo', 'status'], 'other'], + [['-c', 'key=value'], 'other'] + ] as const)('classifies %j as %s', (args, expected) => { + expect(classifyGitCommand(args)).toBe(expected) + }) + + it.each([ + [['--version'], 'other'], + [['branch', '-a'], 'read'], + [['branch', '--show-current'], 'read'], + [['check-ref-format', '--branch', 'topic'], 'read'], + [['checkout', 'topic'], 'other'], + [['commit', '-m', 'message'], 'other'], + [['config', '--get', 'remote.origin.url'], 'read'], + [['config', '--local', 'key', 'value'], 'other'], + [['diff', '--numstat', 'HEAD'], 'read'], + [['fetch', '--prune'], 'network'], + [['init'], 'other'], + [['ls-files', '-z'], 'read'], + [['ls-tree', 'HEAD'], 'read'], + [['merge', '--abort'], 'other'], + [['merge-base', 'HEAD', 'origin/main'], 'read'], + [['pull', '--rebase'], 'network'], + [['rebase', '--abort'], 'other'], + [['remote'], 'read'], + [['remote', '-v'], 'read'], + [['remote', 'get-url', 'origin'], 'read'], + [['restore', '--staged', '--', 'file'], 'other'], + [['rev-list', '--count', 'HEAD'], 'read'], + [['rev-parse', 'HEAD'], 'read'], + [['show', ':file'], 'read'], + [['status', '--porcelain=v2'], 'read'], + [['update-ref', '-d', 'refs/orca/tmp'], 'other'], + [['worktree', 'list', '--porcelain'], 'read'], + [['worktree', 'prune'], 'other'] + ] as const)('covers the production form %j as %s', (args, expected) => { + expect(classifyGitCommand(args)).toBe(expected) + }) +}) diff --git a/src/main/git/wsl-direct-git-read-commands.ts b/src/main/git/wsl-direct-git-read-commands.ts index 18bea5e46ab..1072ceb2107 100644 --- a/src/main/git/wsl-direct-git-read-commands.ts +++ b/src/main/git/wsl-direct-git-read-commands.ts @@ -16,6 +16,7 @@ const ALWAYS_READ_SUBCOMMANDS = new Set([ 'blame', 'cat-file', + 'check-ref-format', 'check-ignore', 'describe', 'diff', @@ -35,10 +36,52 @@ const ALWAYS_READ_SUBCOMMANDS = new Set([ // Read markers that appear as a flag anywhere after the subcommand. const READ_FLAG_SUBCOMMANDS: Record> = { - branch: new Set(['--list', '-l', '--show-current', '--contains', '--points-at']), + branch: new Set([ + '--list', + '-l', + '--show-current', + '--contains', + '--points-at', + '--all', + '-a', + '--remotes', + '-r' + ]), config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l']) } +const BRANCH_MUTATION_FLAGS = new Set([ + '--copy', + '--create-reflog', + '--delete', + '--edit-description', + '--force', + '--move', + '--no-create-reflog', + '--no-track', + '--recurse-submodules', + '--set-upstream-to', + '--track', + '--unset-upstream' +]) +const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u']) + +function hasBranchMutationFlag(args: readonly string[]): boolean { + return args.some((arg) => { + const flag = arg.split('=')[0] + if (BRANCH_MUTATION_FLAGS.has(flag)) { + return true + } + return ( + /^-[^-]/.test(flag) && + flag + .slice(1) + .split('') + .some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part)) + ) + }) +} + // Read markers that must be the *first non-flag* argument, i.e. the action. // Position matters here: matching them anywhere would read `worktree remove list` // as a listing, because a worktree may legitimately be named "list". @@ -52,7 +95,7 @@ const READ_ACTION_SUBCOMMANDS: Record> = { const BARE_FORM_IS_READ = new Set(['remote', 'submodule']) /** Leading `-c key=value` / `--git-dir=...` style options precede the subcommand. */ -function findSubcommandIndex(args: readonly string[]): number { +export function findGitSubcommandIndex(args: readonly string[]): number { for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '-c' || arg === '-C') { @@ -68,7 +111,7 @@ function findSubcommandIndex(args: readonly string[]): number { } export function isWslDirectGitReadCommand(args: readonly string[]): boolean { - const subcommandIndex = findSubcommandIndex(args) + const subcommandIndex = findGitSubcommandIndex(args) if (subcommandIndex === -1) { return false } @@ -86,6 +129,10 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean { return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1 } + if (subcommand === 'branch' && hasBranchMutationFlag(rest)) { + return false + } + const readActions = READ_ACTION_SUBCOMMANDS[subcommand] if (readActions) { const action = rest.find((arg) => !arg.startsWith('-')) @@ -103,3 +150,31 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean { const readFlags = READ_FLAG_SUBCOMMANDS[subcommand] return Boolean(readFlags && rest.some((arg) => readFlags.has(arg.split('=')[0]))) } + +export type GitCommandClass = 'network' | 'read' | 'other' + +const NETWORK_SUBCOMMANDS = new Set(['fetch', 'pull', 'push', 'clone', 'ls-remote']) + +function positionalAction(args: readonly string[], subcommandIndex: number): string | undefined { + return args.slice(subcommandIndex + 1).find((arg) => !arg.startsWith('-')) +} + +/** Classify only commands whose dominant phase is remote transfer as network work. */ +export function classifyGitCommand(args: readonly string[]): GitCommandClass { + const subcommandIndex = findGitSubcommandIndex(args) + if (subcommandIndex === -1) { + return 'other' + } + const subcommand = args[subcommandIndex] + if (NETWORK_SUBCOMMANDS.has(subcommand)) { + return 'network' + } + const action = positionalAction(args, subcommandIndex) + if ( + (subcommand === 'submodule' && action === 'update') || + (subcommand === 'remote' && action === 'update') + ) { + return 'network' + } + return isWslDirectGitReadCommand(args) ? 'read' : 'other' +} diff --git a/src/main/github/client/lookup/pull-request-lookup-data.ts b/src/main/github/client/lookup/pull-request-lookup-data.ts index 5b23c157672..14e3d92337e 100644 --- a/src/main/github/client/lookup/pull-request-lookup-data.ts +++ b/src/main/github/client/lookup/pull-request-lookup-data.ts @@ -5,6 +5,7 @@ import type { PRReviewDecision } from '../../../../shared/github/pull-request-types' import { gitExecFileAsync } from '../../gh-utils' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE @@ -158,7 +159,7 @@ export function normalizePullRequestLookupData(data: PullRequestLookupData): Pul export async function getCurrentHeadOid( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {} ): Promise { const provider = connectionId ? getSshGitProvider(connectionId) : null if (connectionId && !provider) { @@ -171,7 +172,8 @@ export async function getCurrentHeadOid( try { const result = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) return result.stdout.trim() || null } catch { diff --git a/src/main/github/client/lookup/tracked-upstream-branch.ts b/src/main/github/client/lookup/tracked-upstream-branch.ts index b13b97b6fed..c66c188d3d0 100644 --- a/src/main/github/client/lookup/tracked-upstream-branch.ts +++ b/src/main/github/client/lookup/tracked-upstream-branch.ts @@ -4,6 +4,7 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from '../../local-git-config-signature' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' import { TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS, trackedUpstreamSnapshotCache, @@ -24,7 +25,7 @@ export async function getTrackedUpstreamBranch( repoPath: string, branchName: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {} ): Promise { const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions) const now = Date.now() @@ -95,7 +96,7 @@ export async function getTrackedUpstreamBranch( export async function probeTrackedUpstreamSnapshot( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {} ): Promise { const startingGitConfigSignature = await readLocalGitConfigSignature({ repoPath, @@ -129,7 +130,7 @@ export async function probeTrackedUpstreamSnapshot( export async function probeTrackedUpstreamBranches( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {} + localGitOptions: { wslDistro?: string; admissionTier?: GitAdmissionTier } = {} ): Promise<{ probeFailed: boolean upstreamsByBranchName: Map @@ -149,7 +150,8 @@ export async function probeTrackedUpstreamBranches( try { const result = await gitExecFileAsync(args, { cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) return { probeFailed: false, diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts index 506b1c9f9ea..36d96407301 100644 --- a/src/main/github/conflict-summary.test.ts +++ b/src/main/github/conflict-summary.test.ts @@ -298,6 +298,26 @@ describe('getPRConflictSummary caching', () => { ).toHaveLength(1) }) + it('preserves background admission across the complete WSL derivation chain', async () => { + mockGitDispatch() + + await getPRConflictSummary('/repo-root', 'main', 'github-base-oid', 'head-oid-1', { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(5) + for (const [, options] of gitExecFileAsyncMock.mock.calls) { + expect(options).toEqual( + expect.objectContaining({ + cwd: '/repo-root', + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + ) + } + }) + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { mockGitDispatch() diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index b6d19ce1d2a..470f8573367 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -4,6 +4,7 @@ import { isUnsupportedMergeTreeWriteTreeError } from '../../shared/git-merge-tree-capability' import { gitExecFileAsync } from '../git/runner' +import { gitOptionsForWorktree, type GitRuntimeOptions } from '../git/git-runtime-options' import { clearGitCapabilityStateForTests, withLocalGitCapabilityCacheForExecution @@ -21,9 +22,7 @@ import { storeCachedSummary } from './conflict-summary-cache' -type LocalGitExecOptions = { - wslDistro?: string -} +type LocalGitExecOptions = Pick export function __resetPRConflictSummaryCachesForTests(): void { clearGitCapabilityStateForTests() @@ -159,9 +158,8 @@ async function resolveLatestBaseOid( // Why: cap the fetch at 10 s so slow or unreachable remotes don't block // the conflict-summary derivation indefinitely. await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], { - cwd: repoPath, - timeout: 10_000, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions), + timeout: 10_000 }) } catch { // Why: fetching the base ref keeps the conflict list aligned with GitHub's @@ -172,8 +170,7 @@ async function resolveLatestBaseOid( for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) { try { const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) const oid = stdout.trim() if (oid) { @@ -194,8 +191,7 @@ async function resolveMergeBase( localGitOptions: LocalGitExecOptions ): Promise { const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return stdout.trim() } @@ -206,8 +202,7 @@ async function countCommits( localGitOptions: LocalGitExecOptions ): Promise { const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return Number.parseInt(stdout.trim(), 10) || 0 } @@ -251,8 +246,7 @@ async function loadConflictingFiles( async () => { try { const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (error) { @@ -288,8 +282,7 @@ async function loadConflictingFilesWithLegacyMergeTree( ): Promise { try { const result = await gitExecFileAsync(legacyArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...gitOptionsForWorktree(repoPath, localGitOptions) }) return parseMergeTreeNameOnlyOutput(result.stdout) } catch (fallbackError) { diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 1c2d875a56a..c601252deda 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -14,6 +14,7 @@ import { } from './github-remote-identity-parsing' import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution' import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type OwnerRepo = GitHubOwnerRepo @@ -24,10 +25,12 @@ export type GitHubRepoContext = { repoPath: string connectionId?: string | null wslDistro?: string + admissionTier?: GitAdmissionTier } export type LocalGitExecOptions = { wslDistro?: string + admissionTier?: GitAdmissionTier } export type GitHubRemoteIdentityProbeOptions = { @@ -42,7 +45,8 @@ export function githubRepoContext( return { repoPath, connectionId: connectionId ?? null, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) } } @@ -50,12 +54,14 @@ export function ghRepoExecOptions(context: GitHubRepoContext): { cwd?: string encoding?: BufferEncoding wslDistro?: string + admissionTier?: GitAdmissionTier } { return context.connectionId ? {} : { cwd: context.repoPath, - ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) + ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}), + ...(context.admissionTier ? { admissionTier: context.admissionTier } : {}) } } diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts index e2868edf051..f2edf51c676 100644 --- a/src/main/github/pr-refresh-candidate-policy.ts +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -25,11 +25,15 @@ function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boole } export function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate + candidate: PRBranchLookupCandidate, + reason: GitHubPRRefreshReason = 'visible' ): [] | [GitHubPRBranchLookupOptions] { const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + options.localGitExecOptions = { + ...(candidate.localGitOptions?.wslDistro + ? { wslDistro: candidate.localGitOptions.wslDistro } + : {}), + admissionTier: admissionTierForRefreshReason(reason) } if (shouldAcceptMergedFallbackPR(candidate)) { options.acceptMergedFallbackPR = true @@ -40,6 +44,12 @@ export function hostedReviewOptionArgs( return Object.keys(options).length > 0 ? [options] : [] } +export function admissionTierForRefreshReason( + reason: GitHubPRRefreshReason +): 'interactive' | 'background' { + return reason === 'manual' ? 'interactive' : 'background' +} + export function refreshKey(candidate: GitHubPRRefreshCandidate): string { const connectionScope = candidate.connectionId ?? 'local' const runtimeScope = candidate.connectionId diff --git a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts index 78518e659a3..c1f0ccbf76d 100644 --- a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts +++ b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts @@ -231,7 +231,8 @@ describe('pr-refresh-coordinator', () => { 'feature/test', null, null, - null + null, + { localGitExecOptions: { admissionTier: 'background' } } ) expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( 2, @@ -239,7 +240,8 @@ describe('pr-refresh-coordinator', () => { 'feature/test', null, 'ssh-1', - null + null, + { localGitExecOptions: { admissionTier: 'background' } } ) }) @@ -277,7 +279,8 @@ describe('pr-refresh-coordinator', () => { 'feature/test', null, null, - null + null, + { localGitExecOptions: { admissionTier: 'background' } } ) expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith( 2, @@ -286,7 +289,7 @@ describe('pr-refresh-coordinator', () => { null, null, null, - { localGitExecOptions: { wslDistro: 'Ubuntu' } } + { localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'background' } } ) }) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 45a026dabbd..76745cf4789 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -207,7 +207,29 @@ describe('pr-refresh-coordinator', () => { null, null, 12, - { acceptMergedFallbackPR: true } + { + acceptMergedFallbackPR: true, + localGitExecOptions: { admissionTier: 'interactive' } + } + ) + }) + + it('preserves an automatic fallback reason and keeps its git work background', async () => { + const { refreshPRNow } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: Date.now() }) + + await refreshPRNow(makeCandidate(), 'swr') + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + '/repo', + 'feature/test', + null, + null, + null, + { localGitExecOptions: { admissionTier: 'background' } } + ) + expect(sendMock.mock.calls.map(([, event]) => event.reason)).toEqual( + expect.arrayContaining(['swr']) ) }) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 22846cbe17c..d2976079b2a 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -112,7 +112,10 @@ export function _getPRRefreshAliasCountForTests(key: string): number { return queue.aliasCount(key) } -export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { +export async function refreshPRNow( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason = 'manual' +): Promise { const alias = aliasFromCandidate(candidate) const key = refreshKey(candidate) const existing = queue.get(key) @@ -128,7 +131,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason }) return outcome } @@ -139,14 +142,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise key, candidate, aliases: aliasMap, - reason: 'manual', + reason, priority: 40, dueAt: gateUntil, queuedAt: queue.nextOrder() }) events.broadcast({ aliases, - reason: 'manual', + reason, status: 'paused', pausedUntil: gateUntil, skippedReason: 'rate-limit' @@ -165,17 +168,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise queue.delete(key) const requestSequence = events.nextSequence() const requestStartedAt = Date.now() - events.broadcast( - { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, - requestSequence - ) + events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence) const outcome = await getPRForBranchOutcome( candidate.repoPath, candidate.branch, candidate.linkedPRNumber ?? null, candidate.connectionId ?? null, candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(candidate) + ...hostedReviewOptionArgs(candidate, reason) ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome @@ -186,12 +186,14 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise events.observe(candidate, outcome) retry.noteManualGate(key, broadcastOutcome) events.broadcast( - { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, + { aliases, reason, outcome: broadcastOutcome, requestStartedAt }, requestSequence ) drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS + ...(reason === 'manual' + ? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS } + : {}) }) return broadcastOutcome } diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts index d1e37492e32..387c4931868 100644 --- a/src/main/github/pr-refresh-queue-drainer.ts +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -193,7 +193,7 @@ export class PRRefreshQueueDrainer { next.candidate.linkedPRNumber ?? null, next.candidate.connectionId ?? null, next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) + ...hostedReviewOptionArgs(next.candidate, next.reason) ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 577baf9ad67..237c2ffeb90 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -3,9 +3,11 @@ import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type LocalGitExecOptions = { wslDistro?: string + admissionTier?: GitAdmissionTier } const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 @@ -150,7 +152,8 @@ async function probeGlabKnownHosts( timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, ...(!connectionId && localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } - : {}) + : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) const remembered = knownHostsCacheByExecutionContext.get(key) ?? [] diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 96186f1ccf2..b64e8c3f44c 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -1,4 +1,5 @@ import { glabExecFileAsync } from '../git/runner' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' @@ -123,7 +124,8 @@ async function resolveProjectRefForRemote( { repoPath, connectionId, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }, remoteName ) @@ -246,12 +248,13 @@ export function glabRepoExecOptions( repoPath: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): { cwd?: string; wslDistro?: string } { +): { cwd?: string; wslDistro?: string; admissionTier?: GitAdmissionTier } { return connectionId ? {} : { cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) } } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 6387455483b..22e0a15f21f 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -663,6 +663,21 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) }) + it('preserves WSL and background admission on the cold auth-status probe', async () => { + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await getGlabKnownHosts(undefined, { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + }) + it('falls back to default when glab auth status fails', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated')) diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index e7aff1b0e5e..bb0669ec697 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -90,10 +90,13 @@ describe('registerFilesystemHandlers', () => { await handlers.get('git:branchCompare')!(null, { worktreePath: WORKTREE_FEATURE_PATH, - baseRef: 'origin/main' + baseRef: 'origin/main', + admissionTier: 'background' }) - expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {}) + expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', { + admissionTier: 'background' + }) }) it('allows git operations on worktrees outside repo/workspace roots', async () => { @@ -242,7 +245,7 @@ describe('registerFilesystemHandlers', () => { filePath: path.join('src', 'file.ts'), oldPath: path.join('src', 'old-file.ts') }, - {} + { admissionTier: 'interactive' } ) }) diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index 591ea3565f9..8719c8d0f71 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -96,7 +96,9 @@ describe('registerFilesystemHandlers', () => { }) ).resolves.toEqual({ success: true, message: 'Update README' }) - expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) + expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'interactive' + }) expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith(context, params, { kind: 'local', cwd: WORKTREE_FEATURE_PATH @@ -253,6 +255,7 @@ describe('registerFilesystemHandlers', () => { }) expect(getStagedCommitContextMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'interactive', wslDistro: 'Ubuntu' }) expect(prepareForCodexLaunch).toHaveBeenCalledWith({ diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index 0f683936bd7..66b728b49a2 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -80,7 +80,9 @@ describe('registerFilesystemHandlers', () => { }) ).resolves.toEqual({ success: true }) - expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', {}) + expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit', { + admissionTier: 'interactive' + }) }) it('returns local commit hook failure payload from git:commit', async () => { diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index 4f3a62ce679..5cc1b4e9540 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -94,11 +94,9 @@ describe('registerFilesystemHandlers', () => { // Why: validateGitRelativeFilePath uses path.relative() which produces // platform-specific separators (backslashes on Windows). - expect(stageFileMock).toHaveBeenCalledWith( - WORKTREE_FEATURE_PATH, - path.join('src', 'file.ts'), - {} - ) + expect(stageFileMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, path.join('src', 'file.ts'), { + admissionTier: 'interactive' + }) }) it('uses worktree roots seeded by worktrees:list without rebuilding the cache', async () => { @@ -111,7 +109,10 @@ describe('registerFilesystemHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() expect(realpathMock).not.toHaveBeenCalledWith(WORKTREE_FEATURE_PATH) - expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: false }) + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', + includeIgnored: false + }) }) it('passes configured shared links through the local status path', async () => { @@ -135,6 +136,7 @@ describe('registerFilesystemHandlers', () => { await handlers.get('git:status')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', includeIgnored: false, sharedLinkPaths: ['node_modules'] }) @@ -149,7 +151,10 @@ describe('registerFilesystemHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() expect(realpathMock).not.toHaveBeenCalledWith(REPO_PATH) - expect(getStatusMock).toHaveBeenCalledWith(REPO_PATH, { includeIgnored: false }) + expect(getStatusMock).toHaveBeenCalledWith(REPO_PATH, { + admissionTier: 'status', + includeIgnored: false + }) }) it('forwards includeIgnored through local and SSH git status IPC', async () => { @@ -172,8 +177,14 @@ describe('registerFilesystemHandlers', () => { includeIgnored: true }) - expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { includeIgnored: true }) - expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', + includeIgnored: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + admissionTier: 'status', + includeIgnored: true + }) }) it('returns capped-state metadata unchanged across local and SSH status IPC', async () => { @@ -221,10 +232,12 @@ describe('registerFilesystemHandlers', () => { }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', includeIgnored: false, bypassEffectiveUpstreamNegativeCache: true }) expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + admissionTier: 'status', includeIgnored: false, bypassEffectiveUpstreamNegativeCache: true }) @@ -250,15 +263,48 @@ describe('registerFilesystemHandlers', () => { }) expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', includeIgnored: false, reuseLineStats: true }) expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + admissionTier: 'status', includeIgnored: false, reuseLineStats: true }) }) + it('forwards a false line-stats request through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + includeLineStats: false + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + includeLineStats: false + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'status', + includeIgnored: false, + includeLineStats: false + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + admissionTier: 'status', + includeIgnored: false, + includeLineStats: false + }) + }) + it('aborts tokenized local status without crossing renderer boundaries', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) const statusSignals: AbortSignal[] = [] @@ -346,7 +392,9 @@ describe('registerFilesystemHandlers', () => { connectionId: 'ssh-1' }) - expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) + expect(abortMergeMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'interactive' + }) expect(sshProvider.abortMerge).toHaveBeenCalledWith('/remote/repo') }) @@ -366,7 +414,9 @@ describe('registerFilesystemHandlers', () => { connectionId: 'ssh-1' }) - expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {}) + expect(abortRebaseMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + admissionTier: 'interactive' + }) expect(sshProvider.abortRebase).toHaveBeenCalledWith('/remote/repo') }) @@ -410,7 +460,7 @@ describe('registerFilesystemHandlers', () => { expect(bulkStageFilesMock).toHaveBeenCalledWith( WORKTREE_FEATURE_PATH, [path.join('src', 'file.ts'), path.join('nested', 'child.ts')], - {} + { admissionTier: 'interactive' } ) }) @@ -427,7 +477,7 @@ describe('registerFilesystemHandlers', () => { expect(bulkDiscardChangesMock).toHaveBeenCalledWith( WORKTREE_FEATURE_PATH, [path.join('src', 'file.ts'), path.join('nested', 'child.ts')], - {} + { admissionTier: 'interactive' } ) }) diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index 5d7507a5615..8d3601d5baa 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -60,7 +60,7 @@ describe('main Quick Open git directory expansion', () => { .mockResolvedValueOnce(primary) .mockResolvedValueOnce(ignored) - const promise = listFilesWithGit(root, [], {}) + const promise = listFilesWithGit(root, [], { wslDistro: 'Ubuntu' }) await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(1)) await vi.waitFor(() => expect(revParse.listenerCount('close')).toBeGreaterThan(0)) revParse.emit('close', 0, null) @@ -78,6 +78,12 @@ describe('main Quick Open git directory expansion', () => { 'scratch/notes.txt', 'src/index.ts' ]) + expect(gitSpawnMock.mock.calls[1][1]).toEqual( + expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) + ) + expect(gitSpawnMock.mock.calls[2][1]).toEqual( + expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) + ) expect(gitSpawnMock.mock.calls[2][0]).toContain('--directory') }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index bebab27808d..28cee2a4d50 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -132,6 +132,7 @@ export async function listFilesWithGit( // rootPath and use the output directly — no prefix stripping needed. const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], { cwd: rootPath, + admissionTier: 'interactive', ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), signal: scanController.signal, stdio: ['ignore', 'pipe', 'pipe'] diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 4ad492ec10a..b1b06bd2d16 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -37,6 +37,7 @@ import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' import { FileListingCancelledError } from '../../shared/file-listing-cancellation' +import { _gitAdmissionSnapshotForTests } from '../git/command-runner/git-subprocess-admission' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -603,6 +604,7 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/kept.ts']) expect(primary.kill).toHaveBeenCalled() + primary.emit('close', null, 'SIGTERM') }) it('git fallback applies hidden dir blocklist', async () => { @@ -695,6 +697,15 @@ describe('filesystem-list-files', () => { expect(gitP2.kill).toHaveBeenCalled() expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((gitP1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + // Admission retains live children after the listing listeners detach. + const admittedBeforeClose = _gitAdmissionSnapshotForTests().budgets.general?.baseUsed + expect(gitP1.listenerCount('error')).toBe(1) + expect(gitP1.listenerCount('close')).toBe(1) + gitP1.emit('close', null, 'SIGKILL') + gitP2.emit('close', null, 'SIGKILL') + + expect(admittedBeforeClose).toBe(2) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) expect(gitP1.listenerCount('error')).toBe(0) expect(gitP1.listenerCount('close')).toBe(0) } finally { @@ -748,6 +759,10 @@ describe('filesystem-list-files', () => { ) expect(gitP2.kill).toHaveBeenCalled() expect(warnSpy).toHaveBeenCalled() + const admittedBeforeClose = _gitAdmissionSnapshotForTests().budgets.general?.baseUsed + gitP2.emit('close', null, 'SIGKILL') + expect(admittedBeforeClose).toBe(1) + expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed).toBe(0) } finally { vi.useRealTimers() warnSpy.mockRestore() diff --git a/src/main/ipc/filesystem-search-git.test.ts b/src/main/ipc/filesystem-search-git.test.ts index ae73b0aa2e2..7de098199f6 100644 --- a/src/main/ipc/filesystem-search-git.test.ts +++ b/src/main/ipc/filesystem-search-git.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -const { spawnMock } = vi.hoisted(() => ({ - spawnMock: vi.fn() +const { gitSpawnMock } = vi.hoisted(() => ({ + gitSpawnMock: vi.fn() })) -vi.mock('child_process', () => ({ - spawn: spawnMock, - // runner.ts imports these from child_process; stubs prevent - // "missing export" errors when the mock is resolved transitively. - execFile: vi.fn(), - execFileSync: vi.fn() +vi.mock('../git/runner', () => ({ + gitSpawnAfterWindowsEnvironmentReady: gitSpawnMock })) import { searchWithGitGrep } from './filesystem-search-git' @@ -36,7 +32,7 @@ describe('filesystem-search-git', () => { it('parses git grep output and finds matches', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'hello', rootPath: '/mock/root' }, 100) @@ -74,7 +70,7 @@ describe('filesystem-search-git', () => { it('finds multiple matches per line', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'ab', rootPath: '/mock/root' }, 100) @@ -97,7 +93,7 @@ describe('filesystem-search-git', () => { it('respects maxResults and sets truncated', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'x', rootPath: '/mock/root' }, 2) @@ -118,26 +114,30 @@ describe('filesystem-search-git', () => { it('passes correct flags for case-insensitive fixed-string search', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep( '/mock/root', { query: 'test', rootPath: '/mock/root', caseSensitive: false, useRegex: false }, - 100 + 100, + { wslDistro: 'Ubuntu' } ) setTimeout(() => proc.emit('close'), 10) await promise - const gitArgs = spawnMock.mock.calls[0][1] as string[] + const gitArgs = gitSpawnMock.mock.calls[0][0] as string[] expect(gitArgs).toContain('-i') expect(gitArgs).toContain('--fixed-strings') expect(gitArgs).not.toContain('--extended-regexp') + expect(gitSpawnMock.mock.calls[0][1]).toEqual( + expect.objectContaining({ admissionTier: 'interactive', wslDistro: 'Ubuntu' }) + ) }) it('passes correct flags for regex whole-word search', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep( '/mock/root', @@ -154,7 +154,7 @@ describe('filesystem-search-git', () => { setTimeout(() => proc.emit('close'), 10) await promise - const gitArgs = spawnMock.mock.calls[0][1] as string[] + const gitArgs = gitSpawnMock.mock.calls[0][0] as string[] expect(gitArgs).toContain('-w') expect(gitArgs).toContain('--extended-regexp') expect(gitArgs).not.toContain('-i') @@ -163,7 +163,7 @@ describe('filesystem-search-git', () => { it('returns empty result when git grep spawn fails', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'test', rootPath: '/mock/root' }, 100) @@ -180,7 +180,7 @@ describe('filesystem-search-git', () => { try { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) @@ -207,7 +207,7 @@ describe('filesystem-search-git', () => { it('skips lines without null separator', async () => { const proc = createMockProcess() - spawnMock.mockReturnValue(proc) + gitSpawnMock.mockReturnValue(proc) const promise = searchWithGitGrep('/mock/root', { query: 'ok', rootPath: '/mock/root' }, 100) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 0bff6336f66..da54799cad1 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -32,6 +32,7 @@ export async function searchWithGitGrep( const gitArgs = buildGitGrepArgs(args.query, args) const child = await gitSpawnAfterWindowsEnvironmentReady(gitArgs, { cwd: rootPath, + admissionTier: 'interactive', ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0ad3825a7fc..8f59aa00be7 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -28,6 +28,7 @@ import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import type { SshMutationExpectation } from '../../shared/ssh-types' import { sortDirEntries } from '../../shared/file-name-sort' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' @@ -1177,7 +1178,9 @@ export function registerFilesystemHandlers( args: { worktreePath: string connectionId?: string + admissionTier?: GitAdmissionTier includeIgnored?: boolean + includeLineStats?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean branchLineTotalMergeBase?: string @@ -1187,6 +1190,8 @@ export function registerFilesystemHandlers( const controller = gitStatusCancellations.begin(event, args.requestToken) const options = { includeIgnored: args.includeIgnored ?? false, + admissionTier: args.admissionTier ?? ('status' as const), + ...(args.includeLineStats === false ? { includeLineStats: false } : {}), ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), ...(args.branchLineTotalMergeBase === undefined ? {} @@ -1370,7 +1375,7 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await abortMerge(worktreePath, gitOptions) + await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) } ) @@ -1390,7 +1395,7 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await abortRebase(worktreePath, gitOptions) + await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) } ) @@ -1425,7 +1430,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, gitOptions) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -1452,7 +1460,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - return commitChanges(worktreePath, args.message, gitOptions) + return commitChanges(worktreePath, args.message, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -1532,7 +1543,10 @@ export function registerFilesystemHandlers( ) let context try { - context = await getStagedCommitContext(worktreePath, gitOptions) + context = await getStagedCommitContext(worktreePath, { + ...gitOptions, + admissionTier: 'interactive' + }) } catch (error) { console.error('[filesystem] Failed to read staged commit context:', error) return { @@ -1822,14 +1836,23 @@ export function registerFilesystemHandlers( 'git:branchCompare', async ( _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getBranchCompare(args.worktreePath, args.baseRef) + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -1837,7 +1860,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - return getBranchCompare(worktreePath, args.baseRef, gitOptions) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) } ) @@ -1914,9 +1940,15 @@ export function registerFilesystemHandlers( worktreePath ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } - await gitFetch(worktreePath, args.pushTarget, gitOptions) + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -1946,7 +1978,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, gitOptions) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -1983,11 +2018,15 @@ export function registerFilesystemHandlers( worktreePath ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } await gitPush(worktreePath, publish, args.pushTarget, { forceWithLease: args.forceWithLease === true, - ...gitOptions + ...gitOptions, + admissionTier: 'interactive' }) } ) @@ -2015,9 +2054,15 @@ export function registerFilesystemHandlers( worktreePath ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } - await gitPull(worktreePath, args.pushTarget, gitOptions) + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2044,9 +2089,15 @@ export function registerFilesystemHandlers( worktreePath ) if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } - await gitFastForward(worktreePath, args.pushTarget, gitOptions) + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2069,7 +2120,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, gitOptions) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2129,7 +2183,7 @@ export function registerFilesystemHandlers( filePath, oldPath }, - gitOptions + { ...gitOptions, admissionTier: 'interactive' } ) } ) @@ -2179,7 +2233,7 @@ export function registerFilesystemHandlers( filePath, oldPath }, - gitOptions + { ...gitOptions, admissionTier: 'interactive' } ) } ) @@ -2204,7 +2258,7 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await stageFile(worktreePath, filePath, gitOptions) + await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) } ) @@ -2228,7 +2282,7 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await unstageFile(worktreePath, filePath, gitOptions) + await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) } ) @@ -2252,7 +2306,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await discardChanges(worktreePath, filePath, gitOptions) + await discardChanges(worktreePath, filePath, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2276,7 +2333,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await bulkDiscardChanges(worktreePath, filePaths, gitOptions) + await bulkDiscardChanges(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2300,7 +2360,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await bulkStageFiles(worktreePath, filePaths, gitOptions) + await bulkStageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) @@ -2324,7 +2387,10 @@ export function registerFilesystemHandlers( args.worktreePath, worktreePath ) - await bulkUnstageFiles(worktreePath, filePaths, gitOptions) + await bulkUnstageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) } ) diff --git a/src/main/ipc/github-pr-refresh-handlers.ts b/src/main/ipc/github-pr-refresh-handlers.ts index ab4dc74ec7d..11b76fa6545 100644 --- a/src/main/ipc/github-pr-refresh-handlers.ts +++ b/src/main/ipc/github-pr-refresh-handlers.ts @@ -106,10 +106,14 @@ export function registerGitHubPRRefreshHandlers(store: Store, stats: StatsCollec ipcMain.handle( 'gh:refreshPRNow', - async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + async ( + _event, + args: { candidate: GitHubPRRefreshCandidate; reason?: GitHubPRRefreshReason } + ) => { const repo = assertRegisteredGitHubRepo(args.candidate, store) const outcome = await refreshPRNow( - applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate) + applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate), + args.reason ) recordPRIfNeeded(repo, outcome) return outcome diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index c22e9bbfe26..7cad33211d6 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -171,7 +171,7 @@ describe('registerHostedReviewHandlers', () => { title: 'Feature PR' }), null, - { localGitExecOptions: { wslDistro: 'Ubuntu' } } + { localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } } ) }) @@ -212,7 +212,10 @@ describe('registerHostedReviewHandlers', () => { resolvedWorktreePath, expect.anything(), null, - { sharedLinkPaths: ['node_modules'] } + { + localGitExecOptions: { admissionTier: 'interactive' }, + sharedLinkPaths: ['node_modules'] + } ) }) @@ -236,7 +239,9 @@ describe('registerHostedReviewHandlers', () => { title: 'Feature PR' }) - expect(createHostedReviewMock).toHaveBeenCalledWith(worktreePath, expect.anything(), 'ssh-1') + expect(createHostedReviewMock).toHaveBeenCalledWith(worktreePath, expect.anything(), 'ssh-1', { + localGitExecOptions: { admissionTier: 'interactive' } + }) }) it('routes local WSL project review status through main-process runtime options', async () => { @@ -289,7 +294,7 @@ describe('registerHostedReviewHandlers', () => { connectionId: undefined, branch: 'feature/wsl', linkedGitHubPR: 42, - localGitExecOptions: { wslDistro: 'Ubuntu' } + localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'background' } }) ) // Card-list polling is the O(N) tier and must not claim the fast one. @@ -314,6 +319,25 @@ describe('registerHostedReviewHandlers', () => { ) }) + it('uses interactive git admission for an explicit card refresh', async () => { + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + branch: 'feature/refresh', + admissionTier: 'interactive' + }) + + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'feature/refresh', + localGitExecOptions: { admissionTier: 'interactive' } + }) + ) + }) + it('uses the explicit owner when duplicate repos share an id and path', async () => { const localRepo = { ...repo, connectionId: undefined } store.getRepos.mockReturnValue([localRepo, repo]) @@ -411,7 +435,8 @@ describe('registerHostedReviewHandlers', () => { body: null, draft: false }, - 'ssh-1' + 'ssh-1', + { localGitExecOptions: { admissionTier: 'interactive' } } ) expect(resolveRegisteredWorktreePathMock).not.toHaveBeenCalled() expect(stats.record).toHaveBeenCalledWith( @@ -447,7 +472,7 @@ describe('registerHostedReviewHandlers', () => { worktreePath, expect.objectContaining({ base: 'stack/parent', head: 'stack/child' }), 'ssh-1', - {} + { localGitExecOptions: { admissionTier: 'interactive' } } ) expect(createHostedReviewMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 8bd9b7cce91..c431459f4f7 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -103,7 +103,10 @@ function normalizeRemoteHostedReviewPath(remotePath: string): string { export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { const repo = assertRegisteredRepoForBranch(args, store) - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const localGitOptions = { + ...getLocalProjectWorktreeGitOptions(store, repo), + admissionTier: args.admissionTier ?? ('background' as const) + } const review = await getHostedReviewForBranch({ repoPath: repo.path, connectionId: repo.connectionId, @@ -116,7 +119,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector linkedGiteaPR: args.linkedGiteaPR ?? null, currentHeadOid: args.currentHeadOid ?? null, ...(args.active === true ? { active: true } : {}), - ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) + localGitExecOptions: localGitOptions }) if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) { stats.record({ @@ -139,7 +142,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ...args, repoPath: worktreePath, connectionId: repo.connectionId ?? null, - ...(Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}) + localGitExecOptions: { ...localGitOptions, admissionTier: 'interactive' as const } }) } ) @@ -147,7 +150,10 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const localGitOptions = { + ...getLocalProjectWorktreeGitOptions(store, repo), + admissionTier: 'interactive' as const + } // Why: the dirty preflight must not count Orca's own shared symlinks as user work (issue #10451). // Remote creation never materializes them, and `repo.path` is a path on the // remote host — reading it locally would resolve an unrelated `orca.yaml`. @@ -190,7 +196,10 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector async (_event, args: CreateStackedHostedReviewArgs) => { const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath) - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const localGitOptions = { + ...getLocalProjectWorktreeGitOptions(store, repo), + admissionTier: 'interactive' as const + } const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo) const executionOptions = { ...(Object.keys(localGitOptions).length > 0 diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index b6f851590ba..395bfec8001 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -249,6 +249,7 @@ describe('repos:add + repos:clone', () => { expect(gitSpawnMock).toHaveBeenCalledWith( ['clone', '--progress', '--', 'https://example.com/orca.git', join(destination, 'orca')], expect.objectContaining({ + admissionTier: 'interactive', env: expect.objectContaining({ GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index 19ce962057b..920c231aa7b 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -145,6 +145,7 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor ['clone', '--progress', '--', args.url, clonePath], { cwd: args.destination, + admissionTier: 'interactive', // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). env: nonInteractiveGitEnv(), signal: pendingController.signal, diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index a54829c7c25..401c296b1f9 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -45,8 +45,9 @@ export async function readWorkspaceCleanupGitEvidence( status = await withWorkspaceCleanupTimeout( (signal) => repo.connectionId - ? provider!.getStatus(worktree.path, { signal }) + ? provider!.getStatus(worktree.path, { includeLineStats: false, signal }) : getStatus(worktree.path, { + includeLineStats: false, signal, ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) }), diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 1bc9d8740b3..9b616acc3c4 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -197,6 +197,7 @@ describe('workspace cleanup scan', () => { const result = await scanWorkspaceCleanup(makeStore()) expect(getStatusMock).toHaveBeenCalledWith('/repo-feature', { + includeLineStats: false, signal: expect.any(AbortSignal), sharedLinkPaths: ['node_modules'] }) @@ -456,6 +457,7 @@ describe('workspace cleanup scan', () => { signal: expect.any(AbortSignal) }) expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', { + includeLineStats: false, signal: expect.any(AbortSignal) }) expect(result.errors).toEqual([]) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index 369312476f2..bab57b74f64 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -21,7 +21,7 @@ // itself becomes a `noopSpan` that swallows all calls — call sites do not // need to branch on whether tracing is on. -import { withSpan, type ActiveSpan } from './tracer' +import { startSpan, withSpan, type ActiveSpan } from './tracer' const GIT_FAST_SUCCESS_THRESHOLD_MS = 250 const GIT_FAST_SUCCESS_WINDOW_MS = 60_000 @@ -173,17 +173,30 @@ export type GitSpanArgs = { /** Wrap a git execution in a `git.exec` span. Git accepts global options before * the subcommand; promoting the parsed command to its own attribute makes it * grep-friendly without copying the full args array into dashboards. */ -export async function withGitSpan(meta: GitSpanArgs, fn: () => Promise): Promise { +export async function withGitSpan( + meta: GitSpanArgs, + fn: (span: ActiveSpan) => Promise +): Promise { return withSpan( 'git.exec', async (span) => { addGitAttributes(span, meta) - return await fn() + return await fn(span) }, { attributes: { kind: 'git' }, shouldRecord: (record) => shouldRecordGitSpan(meta, record) } ) } +/** Start a git span whose lifetime follows a returned ChildProcess. */ +export function startGitSpan(meta: GitSpanArgs): ActiveSpan { + const span = startSpan('git.exec', { + attributes: { kind: 'git' }, + shouldRecord: (record) => shouldRecordGitSpan(meta, record) + }) + addGitAttributes(span, meta) + return span +} + export type WorktreeSpanArgs = { readonly stage: 'clone' | 'checkout' | 'install' | 'create' | 'remove' readonly path?: string diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index f70f3ddcc72..e6edb9f1c75 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -15,6 +15,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { GitProviderStatusOptions } from './git-provider-status-options' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type { GitProviderStatusOptions } from './git-provider-status-options' @@ -46,7 +47,11 @@ export type IGitProvider = { abortRebase(worktreePath: string): Promise checkoutBranch(worktreePath: string, branch: string): Promise listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }> - getBranchCompare(worktreePath: string, baseRef: string): Promise + getBranchCompare( + worktreePath: string, + baseRef: string, + options?: { admissionTier?: GitAdmissionTier } + ): Promise getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise pushBranch( diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index a8924924f22..1a5d64e7f6a 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -1,6 +1,8 @@ // Kept separate so the central cross-provider contract stays within its enforced size limit. export type GitProviderStatusOptions = { + admissionTier?: 'interactive' | 'status' | 'background' includeIgnored?: boolean + includeLineStats?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean /** Merge-base OID to measure the branch line total against; omit to skip the work. */ diff --git a/src/main/providers/ssh-git-provider-status-lease.test.ts b/src/main/providers/ssh-git-provider-status-lease.test.ts index f3d041836c8..f7a8a09018b 100644 --- a/src/main/providers/ssh-git-provider-status-lease.test.ts +++ b/src/main/providers/ssh-git-provider-status-lease.test.ts @@ -146,7 +146,7 @@ describe('SshGitProvider status read leases', () => { }) it('isolates status reads by worktree and output-affecting options', async () => { - const pendingRequests = Array.from({ length: 5 }, () => + const pendingRequests = Array.from({ length: 8 }, () => deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() ) mux.request.mockImplementation( @@ -157,22 +157,28 @@ describe('SshGitProvider status read leases', () => { provider.getStatus('/home/user/repo'), provider.getStatus('/home/user/other'), provider.getStatus('/home/user/repo', { includeIgnored: true }), + provider.getStatus('/home/user/repo', { includeLineStats: false }), provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }), - provider.getStatus('/home/user/repo', { reuseLineStats: true }) + provider.getStatus('/home/user/repo', { reuseLineStats: true }), + provider.getStatus('/home/user/repo', { admissionTier: 'background' }), + provider.getStatus('/home/user/repo', { admissionTier: 'interactive' }) ] - await waitForRequestCount(mux.request, 5) + await waitForRequestCount(mux.request, 8) expect(mux.request.mock.calls.map(([, payload]) => payload)).toEqual([ { worktreePath: '/home/user/repo' }, { worktreePath: '/home/user/other' }, { worktreePath: '/home/user/repo', includeIgnored: true }, + { worktreePath: '/home/user/repo', includeLineStats: false }, { worktreePath: '/home/user/repo', bypassEffectiveUpstreamNegativeCache: true }, - { worktreePath: '/home/user/repo', reuseLineStats: true } + { worktreePath: '/home/user/repo', reuseLineStats: true }, + { worktreePath: '/home/user/repo', admissionTier: 'background' }, + { worktreePath: '/home/user/repo', admissionTier: 'interactive' } ]) pendingRequests.forEach((pending) => pending.resolve({ entries: [], conflictOperation: 'unknown' }) diff --git a/src/main/providers/ssh-git-provider-status.test.ts b/src/main/providers/ssh-git-provider-status.test.ts index b2814624a6c..1eb727eacfb 100644 --- a/src/main/providers/ssh-git-provider-status.test.ts +++ b/src/main/providers/ssh-git-provider-status.test.ts @@ -57,6 +57,18 @@ describe('SshGitProvider', () => { ) }) + it('getStatus forwards a false line-stats request', async () => { + mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await provider.getStatus('/home/user/repo', { includeLineStats: false }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.status', + { worktreePath: '/home/user/repo', includeLineStats: false }, + { signal: expect.any(AbortSignal) } + ) + }) + it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { const statusResult = { entries: [], conflictOperation: 'unknown' } mux.request.mockResolvedValue(statusResult) @@ -193,10 +205,13 @@ describe('SshGitProvider', () => { const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] } mux.request.mockResolvedValue(compareResult) - const result = await provider.getBranchCompare('/home/user/repo', 'main') + const result = await provider.getBranchCompare('/home/user/repo', 'main', { + admissionTier: 'background' + }) expect(mux.request).toHaveBeenCalledWith('git.branchCompare', { worktreePath: '/home/user/repo', - baseRef: 'main' + baseRef: 'main', + admissionTier: 'background' }) expect(result).toEqual(compareResult) }) diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts index 54720d8d9fb..5cbcbc17b5a 100644 --- a/src/main/providers/ssh-git-read-provider.ts +++ b/src/main/providers/ssh-git-read-provider.ts @@ -57,7 +57,9 @@ export class SshGitReadProvider { this.gitDiffReadDedupe.clear() const request = { worktreePath, + ...(options?.admissionTier ? { admissionTier: options.admissionTier } : {}), ...(options?.includeIgnored ? { includeIgnored: true } : {}), + ...(options?.includeLineStats === false ? { includeLineStats: false } : {}), ...(options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {}), @@ -68,7 +70,9 @@ export class SshGitReadProvider { } const key = stableInFlightKey([ worktreePath, + options?.admissionTier ?? 'status', options?.includeIgnored === true, + options?.includeLineStats !== false, options?.bypassEffectiveUpstreamNegativeCache === true, options?.reuseLineStats === true, options?.branchLineTotalMergeBase ?? '' diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts index 6942b91ca1b..85e2bbb2938 100644 --- a/src/main/providers/ssh-git-working-tree-provider.ts +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -4,6 +4,7 @@ import type { } from '../../shared/git-diff-compare-types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { GitConflictOperation } from '../../shared/git-status-types' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { @@ -106,10 +107,15 @@ export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { } } - async getBranchCompare(worktreePath: string, baseRef: string): Promise { + async getBranchCompare( + worktreePath: string, + baseRef: string, + options: { admissionTier?: GitAdmissionTier } = {} + ): Promise { return (await this.mux.request('git.branchCompare', { worktreePath, - baseRef + baseRef, + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) })) as GitBranchCompareResult } diff --git a/src/main/providers/working-directory-validation.ts b/src/main/providers/working-directory-validation.ts index e43a488443c..688cc852446 100644 --- a/src/main/providers/working-directory-validation.ts +++ b/src/main/providers/working-directory-validation.ts @@ -29,7 +29,7 @@ const uncRouteLanes = new Map() * subdirectories of one share share a lane. Returns null for local-disk paths, * which never block long enough to be worth queueing. */ -function uncRouteKey(cwd: string): string | null { +export function uncRouteKey(cwd: string): string | null { if (!cwd.startsWith('\\\\')) { return null } diff --git a/src/main/runtime/orca-runtime-git-diff-budget.test.ts b/src/main/runtime/orca-runtime-git-diff-budget.test.ts index 2e30f7f931e..b2e894d4bc6 100644 --- a/src/main/runtime/orca-runtime-git-diff-budget.test.ts +++ b/src/main/runtime/orca-runtime-git-diff-budget.test.ts @@ -44,7 +44,10 @@ function oversizedResult(): GitDiffResult { } } -function commands(connectionId?: string): RuntimeGitCommands { +function commands( + connectionId?: string, + localGitOptions?: { wslDistro: string } +): RuntimeGitCommands { const worktree = { id: 'wt-1', repoId: 'repo-1', @@ -54,7 +57,8 @@ function commands(connectionId?: string): RuntimeGitCommands { return new RuntimeGitCommands({ resolveRuntimeGitTarget: async () => ({ worktree, - ...(connectionId ? { connectionId } : {}) + ...(connectionId ? { connectionId } : {}), + ...(localGitOptions ? { localGitOptions } : {}) }), getRuntimeSettings: () => ({}) as GlobalSettings }) @@ -195,4 +199,23 @@ describe('runtime git diff transport budget', () => { } ) }) + + it('prioritizes local file diff reads without losing WSL routing', async () => { + const runtime = commands(undefined, { wslDistro: 'Ubuntu' }) + + await runtime.getRuntimeGitBranchDiff('id:wt-1', BRANCH_COMPARE, 'assets/logo.png') + await runtime.getRuntimeGitCommitDiff('id:wt-1', COMMIT_ARGS) + + const options = { admissionTier: 'interactive', wslDistro: 'Ubuntu' } + expect(mocks.getBranchDiff).toHaveBeenLastCalledWith( + '/remote/repo', + expect.objectContaining({ filePath: 'assets/logo.png' }), + options + ) + expect(mocks.getCommitDiff).toHaveBeenLastCalledWith( + '/remote/repo', + expect.objectContaining({ filePath: 'assets/logo.png' }), + options + ) + }) }) diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 26b993704f2..70fe676f98c 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -9,9 +9,8 @@ import type * as PullRequestContextModule from '../text-generation/pull-request- import { RuntimeGitCommands, type ResolvedRuntimeGitWorktree } from './orca-runtime-git' const mocks = vi.hoisted(() => ({ - abortMerge: vi.fn(), - abortRebase: vi.fn(), checkoutBranch: vi.fn(), + discardChanges: vi.fn(), listLocalBranches: vi.fn(), getStagedCommitContext: vi.fn(), getPullRequestDraftContext: vi.fn(), @@ -26,8 +25,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), - abortMerge: mocks.abortMerge, - abortRebase: mocks.abortRebase, + discardChanges: mocks.discardChanges, getStagedCommitContext: mocks.getStagedCommitContext, getStatus: mocks.getStatus })) @@ -97,8 +95,6 @@ function makeCommands(worktreePath: string): RuntimeGitCommands { describe('RuntimeGitCommands', () => { beforeEach(() => { - mocks.abortMerge.mockReset() - mocks.abortRebase.mockReset() mocks.getStagedCommitContext.mockReset() mocks.getPullRequestDraftContext.mockReset() mocks.generateCommitMessageFromContext.mockReset() @@ -111,6 +107,7 @@ describe('RuntimeGitCommands', () => { mocks.getSshGitProvider.mockReset() mocks.getStatus.mockReset() mocks.checkoutBranch.mockReset() + mocks.discardChanges.mockReset() mocks.listLocalBranches.mockReset() }) @@ -120,17 +117,6 @@ describe('RuntimeGitCommands', () => { } }) - it('aborts a local merge through the resolved worktree', async () => { - const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) - tempDirs.push(worktreePath) - const commands = makeCommands(worktreePath) - mocks.abortMerge.mockResolvedValue(undefined) - - await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true }) - - expect(mocks.abortMerge).toHaveBeenCalledWith(worktreePath, {}) - }) - // Why: a directory-only ignore rule (`node_modules/`) never matches the shared // symlink, so Git reports it untracked forever. Runtime/CLI status has to tell // getStatus which untracked entries are Orca's own (issue #10451); nothing else @@ -148,6 +134,7 @@ describe('RuntimeGitCommands', () => { await commands.getRuntimeGitStatus('id:wt-1') expect(mocks.getStatus).toHaveBeenCalledWith('/workspace/feature', { + admissionTier: 'status', sharedLinkPaths: ['node_modules'] }) }) @@ -170,55 +157,16 @@ describe('RuntimeGitCommands', () => { expect(mocks.getStatus).not.toHaveBeenCalled() }) - it('aborts a remote merge through the SSH git provider', async () => { - const provider = { abortMerge: vi.fn().mockResolvedValue(undefined) } - mocks.getSshGitProvider.mockReturnValue(provider) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - connectionId: 'conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await expect(commands.abortRuntimeGitMerge('id:wt-1')).resolves.toEqual({ ok: true }) - - expect(provider.abortMerge).toHaveBeenCalledWith('/remote/repo') - expect(mocks.abortMerge).not.toHaveBeenCalled() - }) - - it('aborts a local rebase through the resolved worktree', async () => { - const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) - tempDirs.push(worktreePath) - const commands = makeCommands(worktreePath) - mocks.abortRebase.mockResolvedValue(undefined) - - await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true }) - - expect(mocks.abortRebase).toHaveBeenCalledWith(worktreePath, {}) - }) - - it('aborts a remote rebase through the SSH git provider', async () => { - const provider = { abortRebase: vi.fn().mockResolvedValue(undefined) } - mocks.getSshGitProvider.mockReturnValue(provider) - const commands = new RuntimeGitCommands({ - resolveRuntimeGitTarget: async () => ({ - worktree: makeWorktree('/remote/repo'), - connectionId: 'conn-1' - }), - getRuntimeSettings: () => ({}) as GlobalSettings - }) - - await expect(commands.abortRuntimeGitRebase('id:wt-1')).resolves.toEqual({ ok: true }) - - expect(provider.abortRebase).toHaveBeenCalledWith('/remote/repo') - expect(mocks.abortRebase).not.toHaveBeenCalled() - }) - it('checks out a local branch through the resolved worktree', async () => { const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) tempDirs.push(worktreePath) - const commands = makeCommands(worktreePath) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree(worktreePath), + localGitOptions: { wslDistro: 'Ubuntu' } + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) mocks.checkoutBranch.mockResolvedValue(undefined) await expect(commands.checkoutRuntimeGitBranch('id:wt-1', 'feature/x')).resolves.toEqual({ @@ -226,7 +174,10 @@ describe('RuntimeGitCommands', () => { branch: 'feature/x' }) - expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x', {}) + expect(mocks.checkoutBranch).toHaveBeenCalledWith(worktreePath, 'feature/x', { + admissionTier: 'interactive', + wslDistro: 'Ubuntu' + }) }) it('checks out a remote branch through the SSH git provider', async () => { @@ -263,6 +214,40 @@ describe('RuntimeGitCommands', () => { expect(mocks.listLocalBranches).toHaveBeenCalledWith(worktreePath, {}) }) + it('prioritizes a local single-file discard without losing WSL routing', async () => { + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/workspace/repo'), + localGitOptions: { wslDistro: 'Ubuntu' } + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.discardRuntimeGitPath('id:wt-1', 'src/app.ts') + + expect(mocks.discardChanges).toHaveBeenCalledWith('/workspace/repo', 'src/app.ts', { + admissionTier: 'interactive', + wslDistro: 'Ubuntu' + }) + }) + + it('keeps a remote single-file discard owned by the SSH provider', async () => { + const provider = { discardChanges: vi.fn() } + mocks.getSshGitProvider.mockReturnValue(provider) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree('/remote/repo'), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.discardRuntimeGitPath('id:wt-1', 'src/app.ts') + + expect(provider.discardChanges).toHaveBeenCalledWith('/remote/repo', 'src/app.ts') + expect(mocks.discardChanges).not.toHaveBeenCalled() + }) + it('lists remote local branches through the SSH git provider', async () => { const provider = { listLocalBranches: vi.fn().mockResolvedValue({ current: 'main', branches: ['main'] }) @@ -386,6 +371,7 @@ describe('RuntimeGitCommands', () => { }) expect(mocks.getStagedCommitContext).toHaveBeenCalledWith(worktreePath, { + admissionTier: 'interactive', wslDistro: 'Ubuntu' }) expect(prepareForCodexLaunch).toHaveBeenCalledWith({ diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 96c6c4e1886..830f8d49ef9 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7839,6 +7839,24 @@ describe('OrcaRuntimeService', () => { const checkDetailsSignal = new AbortController().signal await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43) + await runtime.getRepoPRForBranch( + 'id:repo-1', + 'feature/wsl-manual', + 42, + 43, + undefined, + undefined, + 'manual' + ) + await runtime.getRepoPRForBranch( + 'id:repo-1', + 'feature/wsl-active', + 42, + 43, + undefined, + undefined, + 'active' + ) await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') @@ -7910,6 +7928,22 @@ describe('OrcaRuntimeService', () => { localGitExecOptions: localGitOptions } ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl-manual', + 42, + null, + null, + { localGitExecOptions: { ...localGitOptions, admissionTier: 'interactive' } } + ) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl-active', + 42, + null, + null, + { localGitExecOptions: { ...localGitOptions, admissionTier: 'background' } } + ) expect(getGitHubWorkItemMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, @@ -8188,7 +8222,8 @@ describe('OrcaRuntimeService', () => { head: 'feature/ssh', title: 'Feature SSH' }), - 'ssh-1' + 'ssh-1', + { localGitExecOptions: { admissionTier: 'interactive' } } ) expect(createStackedHostedReviewMock).toHaveBeenCalledWith( '/remote/repo', @@ -8198,7 +8233,7 @@ describe('OrcaRuntimeService', () => { head: 'feature/ssh' }), 'ssh-1', - {} + { localGitExecOptions: { admissionTier: 'interactive' } } ) }) @@ -8277,7 +8312,7 @@ describe('OrcaRuntimeService', () => { repoPath: TEST_REPO_PATH, connectionId: null, branch: 'feature/wsl', - localGitExecOptions: { wslDistro: 'Ubuntu' } + localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } }) ) expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( @@ -8286,7 +8321,7 @@ describe('OrcaRuntimeService', () => { connectionId: null, branch: 'feature/wsl', linkedGitHubPR: 76, - localGitExecOptions: { wslDistro: 'Ubuntu' } + localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'background' } }) ) expect(createHostedReviewMock).toHaveBeenCalledWith( @@ -8297,7 +8332,7 @@ describe('OrcaRuntimeService', () => { title: 'Feature WSL' }), null, - { localGitExecOptions: { wslDistro: 'Ubuntu' } } + { localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } } ) expect(createStackedHostedReviewMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -8307,7 +8342,7 @@ describe('OrcaRuntimeService', () => { head: 'feature/wsl' }), null, - { localGitExecOptions: { wslDistro: 'Ubuntu' } } + { localGitExecOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } } ) }) @@ -9345,6 +9380,10 @@ describe('OrcaRuntimeService', () => { try { const repo = await runtime.cloneRepo('https://example.com/repo-badge-color.git', '/tmp') + expect(spawnSpy).toHaveBeenCalledWith( + expect.arrayContaining(['clone']), + expect.objectContaining({ admissionTier: 'interactive' }) + ) expect(repo.badgeColor).toBe(DEFAULT_REPO_BADGE_COLOR) expect(added).toEqual([ expect.objectContaining({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 77b63f3819e..65c3b867360 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -167,6 +167,7 @@ import { gitSpawnAfterWindowsEnvironmentReady, nonInteractiveGitEnv } from '../git/runner' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { runWithGitReadCacheInvalidation } from '../git/status' import { wakeFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade-wake' import { @@ -338,7 +339,11 @@ import type { GitHubPRReviewCommentInput, GitHubReactionContent } from '../../shared/github/comment-types' -import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import type { + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { admissionTierForRefreshReason } from '../github/pr-refresh-candidate-policy' import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' import type { ListWorkItemsResult } from '../../shared/github/work-item-types' import type { @@ -23586,6 +23591,7 @@ export class OrcaRuntimeService { ['clone', '--progress', '--', trimmedUrl, clonePath], { cwd: trimmedDestination, + admissionTier: 'interactive', // Why: without the non-interactive guard, a clone that needs GitHub // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth // window on Windows; in a network-restricted env the browser/device @@ -23982,9 +23988,20 @@ export class OrcaRuntimeService { } private getHostedReviewExecutionOptions( - repo: Repo - ): { localGitExecOptions: { wslDistro?: string } } | undefined { - const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} + repo: Repo, + admissionTier?: GitAdmissionTier + ): + | { + localGitExecOptions: { + wslDistro?: string + admissionTier?: GitAdmissionTier + } + } + | undefined { + const localGitOptions = { + ...this.getLocalGitExecutionOptionArgs(repo)[0], + ...(admissionTier && { admissionTier }) + } return Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : undefined @@ -24209,10 +24226,15 @@ export class OrcaRuntimeService { linkedPRNumber?: number | null, fallbackPRNumber?: number | null, acceptMergedFallbackPR?: boolean, - currentHeadOid?: string | null + currentHeadOid?: string | null, + reason?: GitHubPRRefreshReason ): Promise { const repo = await this.resolveRepoSelector(repoSelector) - const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} + const options: GitHubPRBranchLookupOptions = + this.getHostedReviewExecutionOptions( + repo, + reason ? admissionTierForRefreshReason(reason) : undefined + ) ?? {} const lookupOptions = { ...options } if (acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true @@ -24239,6 +24261,7 @@ export class OrcaRuntimeService { async getHostedReviewForBranch(args: { repoSelector: string branch: string + admissionTier?: GitAdmissionTier currentHeadOid?: string | null active?: boolean linkedGitHubPR?: number | null @@ -24249,7 +24272,10 @@ export class OrcaRuntimeService { linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) - const executionOptions = this.getHostedReviewExecutionOptions(repo) + const executionOptions = this.getHostedReviewExecutionOptions( + repo, + args.admissionTier ?? 'background' + ) const review = await getHostedReviewForBranchFromRepo({ repoPath: repo.path, connectionId: repo.connectionId ?? null, @@ -24282,7 +24308,7 @@ export class OrcaRuntimeService { } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') return getHostedReviewCreationEligibilityFromRepo({ repoPath, connectionId: repo.connectionId ?? null, @@ -24306,7 +24332,7 @@ export class OrcaRuntimeService { args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') const input = { provider: args.provider, base: args.base, @@ -24339,7 +24365,7 @@ export class OrcaRuntimeService { args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) - const executionOptions = this.getHostedReviewExecutionOptions(repo) + const executionOptions = this.getHostedReviewExecutionOptions(repo, 'interactive') const result = await createStackedHostedReviewFromRepo( repoPath, { diff --git a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts new file mode 100644 index 00000000000..926aba5b6f9 --- /dev/null +++ b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' + +export const OptionalGitAdmissionTier = z + .unknown() + .optional() + .transform((value): GitAdmissionTier | undefined => { + return value === 'interactive' || value === 'status' || value === 'background' + ? value + : undefined + }) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 00ec4bb6913..f69b01cd053 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { OptionalGitAdmissionTier } from './git-admission-tier-schema' export const WorktreeSelector = z.object({ worktree: z @@ -8,7 +9,9 @@ export const WorktreeSelector = z.object({ }) export const GitStatusParams = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, includeIgnored: z.boolean().optional(), + includeLineStats: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), reuseLineStats: z.boolean().optional(), // Shape is re-validated host-side before it reaches a git argv. @@ -48,6 +51,7 @@ export const GitDiff = GitFilePath.extend({ }) export const GitBranchCompare = WorktreeSelector.extend({ + admissionTier: OptionalGitAdmissionTier, baseRef: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 92221d4bb33..34ec308bfb4 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -25,7 +25,9 @@ describe('git RPC methods', () => { const response = await dispatcher.dispatch(makeRequest('git.status', { worktree: 'id:wt-1' })) - expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + admissionTier: 'status' + }) expect(response).toMatchObject({ ok: true, result: { entries: [], branch: 'main', didHitLimit: true, statusLength: 1_001 } @@ -48,6 +50,7 @@ describe('git RPC methods', () => { ) expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + admissionTier: 'status', includeIgnored: true }) expect(response).toMatchObject({ @@ -56,6 +59,23 @@ describe('git RPC methods', () => { }) }) + it('forwards a false line-stats request through the parsed RPC options', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', includeLineStats: false }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + admissionTier: 'status', + includeLineStats: false + }) + }) + it('forwards upstream-negative-cache bypass for status requests', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -74,6 +94,7 @@ describe('git RPC methods', () => { ) expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + admissionTier: 'status', bypassEffectiveUpstreamNegativeCache: true }) expect(response).toMatchObject({ @@ -99,6 +120,7 @@ describe('git RPC methods', () => { ) expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + admissionTier: 'status', reuseLineStats: true, signal: controller.signal }) @@ -679,6 +701,44 @@ describe('git RPC methods', () => { expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() }) + it('forwards valid branch-compare admission and defaults future tiers', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitBranchCompare: vi.fn().mockResolvedValue({ summary: {}, entries: [] }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const accepted = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'background' + }) + ) + const future = await dispatcher.dispatch( + makeRequest('git.branchCompare', { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'urgent' + }) + ) + + expect(accepted.ok).toBe(true) + expect(runtime.getRuntimeGitBranchCompare).toHaveBeenNthCalledWith( + 1, + 'id:wt-1', + 'origin/main', + 'background' + ) + expect(future.ok).toBe(true) + expect(runtime.getRuntimeGitBranchCompare).toHaveBeenNthCalledWith( + 2, + 'id:wt-1', + 'origin/main', + undefined + ) + }) + it('rejects git history limits above the runtime cap', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index e89113f9b60..ddfbe7bf273 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -28,15 +28,20 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime, signal }) => { const options = params.includeIgnored === undefined && + params.includeLineStats === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && params.branchLineTotalMergeBase === undefined && + params.admissionTier === undefined && signal === undefined ? undefined : { ...(params.includeIgnored === undefined ? {} : { includeIgnored: params.includeIgnored }), + ...(params.includeLineStats === undefined + ? {} + : { includeLineStats: params.includeLineStats }), ...(params.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), @@ -44,10 +49,11 @@ export const GIT_METHODS: RpcMethod[] = [ ...(params.branchLineTotalMergeBase === undefined ? {} : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), + admissionTier: params.admissionTier ?? 'status', ...(signal ? { signal } : {}) } return options === undefined - ? runtime.getRuntimeGitStatus(params.worktree) + ? runtime.getRuntimeGitStatus(params.worktree, { admissionTier: 'status' }) : runtime.getRuntimeGitStatus(params.worktree, options) } }), @@ -103,7 +109,7 @@ export const GIT_METHODS: RpcMethod[] = [ name: 'git.branchCompare', params: GitBranchCompare, handler: async (params, { runtime }) => - runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) + runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef, params.admissionTier) }), defineMethod({ name: 'git.commitCompare', diff --git a/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts b/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts new file mode 100644 index 00000000000..967bafa516e --- /dev/null +++ b/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts @@ -0,0 +1,69 @@ +// Split from github.test.ts to keep it under its line cap. +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { GITHUB_METHODS } from './github' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('github.prForBranch refresh reason', () => { + it('forwards an optional PR refresh reason without requiring it from older clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRepoPRForBranch: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + await dispatcher.dispatch( + makeRequest('github.prForBranch', { + repo: 'repo-1', + branch: 'feature/admission', + reason: 'manual' + }) + ) + await dispatcher.dispatch( + makeRequest('github.prForBranch', { repo: 'repo-1', branch: 'feature/legacy' }) + ) + await dispatcher.dispatch( + makeRequest('github.prForBranch', { + repo: 'repo-1', + branch: 'feature/future', + reason: 'next-generation-refresh' + }) + ) + + expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith( + 1, + 'repo-1', + 'feature/admission', + undefined, + undefined, + undefined, + undefined, + 'manual' + ) + expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith( + 2, + 'repo-1', + 'feature/legacy', + undefined, + undefined, + undefined, + undefined, + undefined + ) + expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith( + 3, + 'repo-1', + 'feature/future', + undefined, + undefined, + undefined, + undefined, + undefined + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts index 30aea3f2c6c..958e08c439a 100644 --- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts +++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts @@ -2,9 +2,24 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalString, requiredString } from '../schemas' import { RepoSelector, SlugRepo } from './github-repo-target-schemas' +import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types' + +const OptionalPRRefreshReason = z + .unknown() + .optional() + .transform((value): GitHubPRRefreshReason | undefined => { + return value === 'visible' || + value === 'active' || + value === 'post-push' || + value === 'manual' || + value === 'swr' + ? value + : undefined + }) const PrForBranch = RepoSelector.extend({ branch: requiredString('Missing branch'), + reason: OptionalPRRefreshReason, linkedPRNumber: z.number().int().positive().nullable().optional(), fallbackPRNumber: z.number().int().positive().nullable().optional(), acceptMergedFallbackPR: z.boolean().optional(), @@ -75,7 +90,8 @@ export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [ params.linkedPRNumber, params.fallbackPRNumber, params.acceptMergedFallbackPR, - params.currentHeadOid + params.currentHeadOid, + params.reason ) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index ba4b05790b7..d490c44dbf4 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -71,6 +71,26 @@ describe('hosted review RPC methods', () => { ) }) + it('carries interactive card refresh admission 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/refresh', + admissionTier: 'interactive' + }) + ) + + expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith( + expect.objectContaining({ admissionTier: 'interactive' }) + ) + }) + 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 0d48b02fb19..fae2e8eb162 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -1,10 +1,12 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { requiredString } from '../schemas' +import { OptionalGitAdmissionTier } from './git-admission-tier-schema' const HostedReviewForBranch = z.object({ repo: requiredString('Missing repo selector'), branch: requiredString('Missing branch'), + admissionTier: OptionalGitAdmissionTier, currentHeadOid: z.string().nullable().optional(), // Only the caller's selected worktree; the host caps how many earn the fast tier. active: z.boolean().optional(), @@ -55,6 +57,7 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [ return runtime.getHostedReviewForBranch({ repoSelector: params.repo, branch: params.branch, + ...(params.admissionTier ? { admissionTier: params.admissionTier } : {}), currentHeadOid: params.currentHeadOid ?? null, ...(params.active === true ? { active: true } : {}), linkedGitHubPR: params.linkedGitHubPR ?? null, diff --git a/src/main/runtime/runtime-git-branch-compare-admission.test.ts b/src/main/runtime/runtime-git-branch-compare-admission.test.ts new file mode 100644 index 00000000000..f2b77c209de --- /dev/null +++ b/src/main/runtime/runtime-git-branch-compare-admission.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' + +const mocks = vi.hoisted(() => ({ + getBranchCompare: vi.fn(), + getSshGitProvider: vi.fn() +})) + +vi.mock('../git/status', () => ({ + getBranchCompare: mocks.getBranchCompare, + getBranchDiff: vi.fn(), + getCommitCompare: vi.fn(), + getCommitDiff: vi.fn(), + getDiff: vi.fn() +})) +vi.mock('../git/repo', () => ({ getRemoteCommitUrl: vi.fn(), getRemoteFileUrl: vi.fn() })) +vi.mock('../git/runner', () => ({ awaitWindowsHostGitEnvironmentReady: vi.fn() })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable' +})) + +import { RuntimeGitDiffCommands } from './runtime-git-diff-commands' + +function makeCommands(overrides: Partial = {}): RuntimeGitDiffCommands { + const target = { + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: 'C:\\repo', + git: { + path: 'C:\\repo', + branch: 'feature', + isBare: false, + isMainWorktree: false, + head: 'a'.repeat(40) + } + }, + localGitOptions: { wslDistro: 'Ubuntu' }, + ...overrides + } as RuntimeGitTarget + const host = { + resolveRuntimeGitTarget: async () => target, + getRuntimeSettings: () => ({}) + } as unknown as RuntimeGitCommandHost + return new RuntimeGitDiffCommands(host) +} + +describe('RuntimeGitDiffCommands branch-compare admission', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getBranchCompare.mockResolvedValue({ summary: {}, entries: [] }) + }) + + it('preserves a background tier with WSL routing and defaults legacy callers to interactive', async () => { + const commands = makeCommands() + + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main') + + expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(1, 'C:\\repo', 'origin/main', { + wslDistro: 'Ubuntu', + admissionTier: 'background' + }) + expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(2, 'C:\\repo', 'origin/main', { + wslDistro: 'Ubuntu', + admissionTier: 'interactive' + }) + }) + + it('forwards background admission to the SSH execution host', async () => { + const getBranchCompare = vi.fn().mockResolvedValue({ summary: {}, entries: [] }) + mocks.getSshGitProvider.mockReturnValue({ getBranchCompare }) + const commands = makeCommands({ connectionId: 'conn-1' }) + + await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background') + + expect(getBranchCompare).toHaveBeenCalledWith('C:\\repo', 'origin/main', { + admissionTier: 'background' + }) + expect(mocks.getBranchCompare).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-git-diff-commands.ts b/src/main/runtime/runtime-git-diff-commands.ts index 52e436fc67f..a6d94775ae5 100644 --- a/src/main/runtime/runtime-git-diff-commands.ts +++ b/src/main/runtime/runtime-git-diff-commands.ts @@ -13,6 +13,7 @@ import { getDiff } from '../git/status' import { awaitWindowsHostGitEnvironmentReady } from '../git/runner' +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE @@ -48,20 +49,18 @@ export class RuntimeGitDiffCommands { ) } return assertGitDiffWithinTransportBudget( - await getDiff( - target.worktree.path, - relativePath, - staged, - compareAgainstHead, - localGitOptionsForTarget(target) - ), + await getDiff(target.worktree.path, relativePath, staged, compareAgainstHead, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }), maxContentBytes ) } async getRuntimeGitBranchCompare( worktreeSelector: string, - baseRef: string + baseRef: string, + admissionTier: GitAdmissionTier = 'interactive' ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -69,9 +68,12 @@ export class RuntimeGitDiffCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.getBranchCompare(target.worktree.path, baseRef) + return provider.getBranchCompare(target.worktree.path, baseRef, { admissionTier }) } - return getBranchCompare(target.worktree.path, baseRef, localGitOptionsForTarget(target)) + return getBranchCompare(target.worktree.path, baseRef, { + ...localGitOptionsForTarget(target), + admissionTier + }) } async getRuntimeGitCommitCompare( @@ -86,7 +88,10 @@ export class RuntimeGitDiffCommands { } return provider.getCommitCompare(target.worktree.path, commitId) } - return getCommitCompare(target.worktree.path, commitId, localGitOptionsForTarget(target)) + return getCommitCompare(target.worktree.path, commitId, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) } async getRuntimeGitBranchDiff( @@ -130,7 +135,10 @@ export class RuntimeGitDiffCommands { filePath: relativePath, oldPath: oldRelativePath }, - localGitOptionsForTarget(target) + { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + } ), maxContentBytes ) @@ -168,7 +176,10 @@ export class RuntimeGitDiffCommands { filePath: relativePath, oldPath: oldRelativePath }, - localGitOptionsForTarget(target) + { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + } ), maxContentBytes ) diff --git a/src/main/runtime/runtime-git-generation-admission.test.ts b/src/main/runtime/runtime-git-generation-admission.test.ts new file mode 100644 index 00000000000..2569ae47749 --- /dev/null +++ b/src/main/runtime/runtime-git-generation-admission.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' + +const mocks = vi.hoisted(() => ({ + getStagedCommitContext: vi.fn(), + gitExecFileAsync: vi.fn(), + getPullRequestDraftContext: vi.fn(), + loadPullRequestLinkedIssue: vi.fn(), + getSshGitProvider: vi.fn(), + prepareLocalCommitMessageAgentEnv: vi.fn(), + generateCommitMessageFromContext: vi.fn(), + generatePullRequestFieldsFromContext: vi.fn(), + resolveHostedReviewBodyForGeneration: vi.fn() +})) + +vi.mock('../git/status', () => ({ getStagedCommitContext: mocks.getStagedCommitContext })) +vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) +vi.mock('../text-generation/pull-request-context', () => ({ + getPullRequestDraftContext: mocks.getPullRequestDraftContext +})) +vi.mock('../source-control/pull-request-linked-issue', () => ({ + loadPullRequestLinkedIssue: mocks.loadPullRequestLinkedIssue +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable' +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: mocks.prepareLocalCommitMessageAgentEnv +})) +vi.mock('../text-generation/commit-message-text-generation', () => ({ + generateCommitMessageFromContext: mocks.generateCommitMessageFromContext, + generatePullRequestFieldsFromContext: mocks.generatePullRequestFieldsFromContext +})) +vi.mock('../source-control/pull-request-template', () => ({ + resolveHostedReviewBodyForGeneration: mocks.resolveHostedReviewBodyForGeneration +})) + +import { RuntimeGitGenerationCommands } from './runtime-git-generation-commands' + +const params = { agentId: 'codex' as const, model: 'gpt-5.5' } +const settingsOverride = { sourceControlAiResolvedParams: params } +const pullRequestContext = { + base: 'main', + branch: 'feature/admission', + commitSummary: 'abc123 feat: test', + changeSummary: 'M README.md', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false +} + +function makeTarget(path: string, overrides: Partial = {}): RuntimeGitTarget { + return { + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path, + git: { path, branch: 'main', isBare: false, isMainWorktree: false, head: 'a'.repeat(40) } + } as RuntimeGitTarget['worktree'], + ...overrides + } +} + +function makeCommands(target: RuntimeGitTarget): RuntimeGitGenerationCommands { + const host: RuntimeGitCommandHost = { + resolveRuntimeGitTarget: async () => target, + getRuntimeSettings: () => ({}) as GlobalSettings + } + return new RuntimeGitGenerationCommands(host) +} + +describe('RuntimeGitGenerationCommands admission', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.gitExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }) + mocks.loadPullRequestLinkedIssue.mockResolvedValue(null) + mocks.resolveHostedReviewBodyForGeneration.mockImplementation(async ({ body }) => body) + mocks.prepareLocalCommitMessageAgentEnv.mockResolvedValue({ ok: true, env: {} }) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'feat' }) + mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) + }) + + it('marks local staged commit context as interactive without dropping WSL routing', async () => { + mocks.getStagedCommitContext.mockResolvedValue({ + branch: 'main', + stagedSummary: 'M README.md', + stagedPatch: '+hello' + }) + const commands = makeCommands( + makeTarget('C:\\repo', { localGitOptions: { wslDistro: 'Ubuntu' } }) + ) + + await commands.generateRuntimeCommitMessage('id:wt-1', settingsOverride) + + expect(mocks.getStagedCommitContext).toHaveBeenCalledWith('C:\\repo', { + wslDistro: 'Ubuntu', + admissionTier: 'interactive' + }) + }) + + it('marks local pull-request context and linked-issue reads as interactive', async () => { + mocks.getPullRequestDraftContext.mockImplementation(async (execute) => { + await execute(['fetch', 'origin', 'main'], { timeout: 123 }) + return pullRequestContext + }) + const commands = makeCommands( + makeTarget('C:\\repo', { localGitOptions: { wslDistro: 'Ubuntu' } }) + ) + + await commands.generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + settingsOverride + ) + + expect(mocks.gitExecFileAsync).toHaveBeenCalledWith(['fetch', 'origin', 'main'], { + cwd: 'C:\\repo', + wslDistro: 'Ubuntu', + timeout: 123, + admissionTier: 'interactive' + }) + expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: undefined, + localGitOptions: { wslDistro: 'Ubuntu', admissionTier: 'interactive' } + }) + ) + }) + + it('keeps SSH pull-request context reads on the remote provider', async () => { + const exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + mocks.getSshGitProvider.mockReturnValue({ exec, executeCommitMessagePlan: vi.fn() }) + mocks.getPullRequestDraftContext.mockImplementation(async (execute) => { + await execute(['log', '--oneline']) + return pullRequestContext + }) + const commands = makeCommands( + makeTarget('/remote/repo', { + connectionId: 'conn-1', + localGitOptions: { wslDistro: 'Ubuntu' } + }) + ) + + await commands.generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + settingsOverride + ) + + expect(exec).toHaveBeenCalledWith(['log', '--oneline'], '/remote/repo') + expect(mocks.gitExecFileAsync).not.toHaveBeenCalled() + expect(mocks.loadPullRequestLinkedIssue).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'conn-1', localGitOptions: {} }) + ) + }) +}) diff --git a/src/main/runtime/runtime-git-generation-commands.ts b/src/main/runtime/runtime-git-generation-commands.ts index 0bbabc39e28..868b97acd89 100644 --- a/src/main/runtime/runtime-git-generation-commands.ts +++ b/src/main/runtime/runtime-git-generation-commands.ts @@ -89,7 +89,10 @@ export class RuntimeGitGenerationCommands { let context: CommitMessageDraftContext | null try { - context = await getStagedCommitContext(target.worktree.path, localGitOptionsForTarget(target)) + context = await getStagedCommitContext(target.worktree.path, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) } catch (error) { console.error('[runtime-git] Failed to read staged commit context:', error) return { success: false, error: 'Failed to read staged changes.' } @@ -166,7 +169,12 @@ export class RuntimeGitGenerationCommands { provider: input.provider, repoPath: target.worktree.path, connectionId: target.connectionId, - localGitOptions: localGitOptionsForTarget(target) + localGitOptions: target.connectionId + ? {} + : { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + } }) let context: Awaited> try { @@ -189,7 +197,8 @@ export class RuntimeGitGenerationCommands { gitExecFileAsync(argv, { cwd: target.worktree.path, ...localGitOptionsForTarget(target), - ...options + ...options, + admissionTier: 'interactive' }), { base: input.base, diff --git a/src/main/runtime/runtime-git-staging-commands.ts b/src/main/runtime/runtime-git-staging-commands.ts index 3ffb7590de6..a856be7bf7b 100644 --- a/src/main/runtime/runtime-git-staging-commands.ts +++ b/src/main/runtime/runtime-git-staging-commands.ts @@ -30,7 +30,10 @@ export class RuntimeGitStagingCommands { await provider.stageFile(target.worktree.path, relativePath) return { ok: true } } - await stageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target)) + await stageFile(target.worktree.path, relativePath, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -45,7 +48,10 @@ export class RuntimeGitStagingCommands { await provider.unstageFile(target.worktree.path, relativePath) return { ok: true } } - await unstageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target)) + await unstageFile(target.worktree.path, relativePath, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -63,7 +69,10 @@ export class RuntimeGitStagingCommands { await provider.bulkStageFiles(target.worktree.path, relativePaths) return { ok: true } } - await bulkStageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) + await bulkStageFiles(target.worktree.path, relativePaths, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -81,7 +90,10 @@ export class RuntimeGitStagingCommands { await provider.bulkUnstageFiles(target.worktree.path, relativePaths) return { ok: true } } - await bulkUnstageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) + await bulkUnstageFiles(target.worktree.path, relativePaths, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -99,7 +111,10 @@ export class RuntimeGitStagingCommands { await provider.bulkDiscardChanges(target.worktree.path, relativePaths) return { ok: true } } - await bulkDiscardChanges(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) + await bulkDiscardChanges(target.worktree.path, relativePaths, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -114,7 +129,10 @@ export class RuntimeGitStagingCommands { await provider.discardChanges(target.worktree.path, relativePath) return { ok: true } } - await discardChanges(target.worktree.path, relativePath, localGitOptionsForTarget(target)) + await discardChanges(target.worktree.path, relativePath, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } } diff --git a/src/main/runtime/runtime-git-status-admission.test.ts b/src/main/runtime/runtime-git-status-admission.test.ts new file mode 100644 index 00000000000..1a0c5a0a47f --- /dev/null +++ b/src/main/runtime/runtime-git-status-admission.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitAdmissionEvent } from '../git/command-runner/git-admission-state' +import { GitAdmissionScheduler } from '../git/command-runner/git-subprocess-admission' +import type * as GitStatusModule from '../git/status' +import type { OrcaRuntimeService } from './orca-runtime' +import { RpcDispatcher } from './rpc/dispatcher' +import { GIT_METHODS } from './rpc/methods/git' +import { RuntimeGitStatusCommands } from './runtime-git-status-commands' + +const getStatusMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + getStatus: getStatusMock +})) + +describe('runtime git status admission', () => { + it('admits RPC reads at the caller tier and defaults future tiers to status', async () => { + const events: GitAdmissionEvent[] = [] + const scheduler = new GitAdmissionScheduler({ + onAdmissionEvent: (event) => events.push(event) + }) + getStatusMock.mockImplementation(async (worktreePath, options) => { + const grant = await scheduler.acquire({ + args: ['status'], + cwd: worktreePath, + tier: options.admissionTier + }) + grant.release() + return { entries: [], conflictOperation: 'none' } + }) + const commands = new RuntimeGitStatusCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: { path: '/workspace/feature' } }) + } as never) + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: commands.getRuntimeGitStatus.bind(commands) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + for (const admissionTier of ['background', 'interactive', 'future-tier']) { + const response = await dispatcher.dispatch({ + id: `request-${admissionTier}`, + authToken: 'token', + method: 'git.status', + params: { worktree: 'id:wt-1', admissionTier } + }) + expect(response.ok).toBe(true) + } + + expect(events.filter((event) => event.phase === 'grant').map((event) => event.tier)).toEqual([ + 'background', + 'interactive', + 'status' + ]) + }) +}) diff --git a/src/main/runtime/runtime-git-status-commands.ts b/src/main/runtime/runtime-git-status-commands.ts index a11a5a8efb2..e76342aca1a 100644 --- a/src/main/runtime/runtime-git-status-commands.ts +++ b/src/main/runtime/runtime-git-status-commands.ts @@ -38,7 +38,10 @@ export class RuntimeGitStatusCommands { ? provider.getStatus(target.worktree.path, options) : provider.getStatus(target.worktree.path) } - const gitOptions = localGitOptionsForTarget(target) + const gitOptions = { + ...localGitOptionsForTarget(target), + admissionTier: options?.admissionTier ?? ('status' as const) + } // Why: shared symlinks do not match Git's directory-only ignore rules. const sharedLinkPaths = target.repo ? getWorktreeSharedLinkPaths(target.repo) : [] const sharedOptions = sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {} @@ -62,6 +65,7 @@ export class RuntimeGitStatusCommands { } return getGitSubmoduleStatus(target.worktree.path, submodulePath, { ...localGitOptionsForTarget(target), + admissionTier: 'interactive', ...(area === 'staged' ? { staged: true } : {}) }) } @@ -78,7 +82,10 @@ export class RuntimeGitStatusCommands { } return provider.checkIgnoredPaths(target.worktree.path, relativePaths) } - return checkIgnoredPaths(target.worktree.path, relativePaths, localGitOptionsForTarget(target)) + return checkIgnoredPaths(target.worktree.path, relativePaths, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) } async getRuntimeGitHistory( @@ -95,7 +102,8 @@ export class RuntimeGitStatusCommands { } return getGitHistory(target.worktree.path, { ...options, - ...localGitOptionsForTarget(target) + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' }) } @@ -124,7 +132,10 @@ export class RuntimeGitStatusCommands { await provider.checkoutBranch(target.worktree.path, branch) return { ok: true, branch } } - await checkoutBranch(target.worktree.path, branch, localGitOptionsForTarget(target)) + await checkoutBranch(target.worktree.path, branch, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true, branch } } diff --git a/src/main/runtime/runtime-git-sync-commands.test.ts b/src/main/runtime/runtime-git-sync-commands.test.ts new file mode 100644 index 00000000000..f662c909cb6 --- /dev/null +++ b/src/main/runtime/runtime-git-sync-commands.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type * as GitRemoteModule from '../git/remote' +import type * as GitStatusModule from '../git/status' +import type * as GitForkSyncModule from '../git/fork-sync' +import type { ResolvedRuntimeGitWorktree } from './runtime-git-command-target' +import { RuntimeGitSyncCommands } from './runtime-git-sync-commands' + +const mocks = vi.hoisted(() => ({ + abortMerge: vi.fn(), + abortRebase: vi.fn(), + commitChanges: vi.fn(), + getSshGitProvider: vi.fn(), + gitSyncForkDefaultBranch: vi.fn(), + gitFastForward: vi.fn(), + gitFetch: vi.fn(), + gitPull: vi.fn() +})) + +vi.mock('../git/fork-sync', async () => ({ + ...(await vi.importActual('../git/fork-sync')), + gitSyncForkDefaultBranch: mocks.gitSyncForkDefaultBranch +})) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + abortMerge: mocks.abortMerge, + abortRebase: mocks.abortRebase, + commitChanges: mocks.commitChanges +})) + +vi.mock('../git/remote', async () => ({ + ...(await vi.importActual('../git/remote')), + gitFastForward: mocks.gitFastForward, + gitFetch: mocks.gitFetch, + gitPull: mocks.gitPull +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: mocks.getSshGitProvider +})) + +const worktree = { + id: 'wt-1', + path: '/workspace/repo' +} as ResolvedRuntimeGitWorktree +const pushTarget = { + remoteName: 'origin', + branchName: 'main' +} satisfies GitPushTarget +const expectedUpstream = { owner: 'stablyai', repo: 'orca' } + +describe('RuntimeGitSyncCommands admission', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('prioritizes local runtime git actions and preserves host routing', async () => { + const commands = new RuntimeGitSyncCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree, + localGitOptions: { wslDistro: 'Ubuntu' } + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + mocks.commitChanges.mockResolvedValue({ success: true }) + mocks.gitSyncForkDefaultBranch.mockResolvedValue({ status: 'up-to-date' }) + + await commands.abortRuntimeGitMerge('id:wt-1') + await commands.abortRuntimeGitRebase('id:wt-1') + await commands.fetchRuntimeGit('id:wt-1', pushTarget) + await commands.syncRuntimeGitForkDefaultBranch('id:wt-1', expectedUpstream) + await commands.pullRuntimeGit('id:wt-1', pushTarget) + await commands.fastForwardRuntimeGit('id:wt-1', pushTarget) + await commands.commitRuntimeGit('id:wt-1', 'feat: prioritize user action') + + const options = { admissionTier: 'interactive', wslDistro: 'Ubuntu' } + expect(mocks.abortMerge).toHaveBeenCalledWith(worktree.path, options) + expect(mocks.abortRebase).toHaveBeenCalledWith(worktree.path, options) + expect(mocks.gitFetch).toHaveBeenCalledWith(worktree.path, pushTarget, options) + expect(mocks.gitSyncForkDefaultBranch).toHaveBeenCalledWith( + worktree.path, + expectedUpstream, + options + ) + expect(mocks.gitPull).toHaveBeenCalledWith(worktree.path, pushTarget, options) + expect(mocks.gitFastForward).toHaveBeenCalledWith(worktree.path, pushTarget, options) + expect(mocks.commitChanges).toHaveBeenCalledWith( + worktree.path, + 'feat: prioritize user action', + options + ) + }) + + it('keeps remote runtime git actions owned by the SSH provider', async () => { + const provider = { + abortMerge: vi.fn(), + abortRebase: vi.fn(), + commit: vi.fn().mockResolvedValue({ success: true }), + fastForwardBranch: vi.fn(), + fetchRemote: vi.fn(), + syncForkDefaultBranch: vi.fn().mockResolvedValue({ status: 'up-to-date' }), + pullBranch: vi.fn() + } + mocks.getSshGitProvider.mockReturnValue(provider) + const commands = new RuntimeGitSyncCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree, + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.abortRuntimeGitMerge('id:wt-1') + await commands.abortRuntimeGitRebase('id:wt-1') + await commands.fetchRuntimeGit('id:wt-1', pushTarget) + await commands.syncRuntimeGitForkDefaultBranch('id:wt-1', expectedUpstream) + await commands.pullRuntimeGit('id:wt-1', pushTarget) + await commands.fastForwardRuntimeGit('id:wt-1', pushTarget) + await commands.commitRuntimeGit('id:wt-1', 'feat: keep execution remote') + + expect(provider.abortMerge).toHaveBeenCalledWith(worktree.path) + expect(provider.abortRebase).toHaveBeenCalledWith(worktree.path) + expect(provider.fetchRemote).toHaveBeenCalledWith(worktree.path, pushTarget) + expect(provider.syncForkDefaultBranch).toHaveBeenCalledWith(worktree.path, expectedUpstream) + expect(provider.pullBranch).toHaveBeenCalledWith(worktree.path, pushTarget) + expect(provider.fastForwardBranch).toHaveBeenCalledWith(worktree.path, pushTarget) + expect(provider.commit).toHaveBeenCalledWith(worktree.path, 'feat: keep execution remote') + expect(mocks.abortMerge).not.toHaveBeenCalled() + expect(mocks.abortRebase).not.toHaveBeenCalled() + expect(mocks.gitFetch).not.toHaveBeenCalled() + expect(mocks.gitSyncForkDefaultBranch).not.toHaveBeenCalled() + expect(mocks.gitPull).not.toHaveBeenCalled() + expect(mocks.gitFastForward).not.toHaveBeenCalled() + expect(mocks.commitChanges).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index fe18012bbab..7cc892bac75 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -24,7 +24,10 @@ export class RuntimeGitSyncCommands { await provider.abortMerge(target.worktree.path) return { ok: true } } - await abortMerge(target.worktree.path, localGitOptionsForTarget(target)) + await abortMerge(target.worktree.path, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -38,7 +41,10 @@ export class RuntimeGitSyncCommands { await provider.abortRebase(target.worktree.path) return { ok: true } } - await abortRebase(target.worktree.path, localGitOptionsForTarget(target)) + await abortRebase(target.worktree.path, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -70,7 +76,10 @@ export class RuntimeGitSyncCommands { await provider.fetchRemote(target.worktree.path, pushTarget) return { ok: true } } - await gitFetch(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) + await gitFetch(target.worktree.path, pushTarget, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -86,11 +95,10 @@ export class RuntimeGitSyncCommands { } return provider.syncForkDefaultBranch(target.worktree.path, expectedUpstream) } - return gitSyncForkDefaultBranch( - target.worktree.path, - expectedUpstream, - localGitOptionsForTarget(target) - ) + return gitSyncForkDefaultBranch(target.worktree.path, expectedUpstream, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) } async pullRuntimeGit( @@ -106,7 +114,10 @@ export class RuntimeGitSyncCommands { await provider.pullBranch(target.worktree.path, pushTarget) return { ok: true } } - await gitPull(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) + await gitPull(target.worktree.path, pushTarget, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -123,7 +134,10 @@ export class RuntimeGitSyncCommands { await provider.fastForwardBranch(target.worktree.path, pushTarget) return { ok: true } } - await gitFastForward(target.worktree.path, pushTarget, localGitOptionsForTarget(target)) + await gitFastForward(target.worktree.path, pushTarget, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -137,7 +151,10 @@ export class RuntimeGitSyncCommands { await provider.rebaseFromBase(target.worktree.path, baseRef) return { ok: true } } - await gitPullRebaseFromBase(target.worktree.path, baseRef, localGitOptionsForTarget(target)) + await gitPullRebaseFromBase(target.worktree.path, baseRef, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) return { ok: true } } @@ -160,7 +177,8 @@ export class RuntimeGitSyncCommands { } await gitPush(target.worktree.path, publish === true, pushTarget, { forceWithLease: forceWithLease === true, - ...localGitOptionsForTarget(target) + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' }) return { ok: true } } @@ -180,6 +198,9 @@ export class RuntimeGitSyncCommands { } return provider.commit(target.worktree.path, message) } - return commitChanges(target.worktree.path, message, localGitOptionsForTarget(target)) + return commitChanges(target.worktree.path, message, { + ...localGitOptionsForTarget(target), + admissionTier: 'interactive' + }) } } diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts index 9c590994c41..88f4aefb186 100644 --- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts @@ -628,7 +628,7 @@ describe('OrcaRuntimeRpcServer', () => { expectedCodexResetScope ) expect(mocks.readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined }) - expect(mocks.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1') + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { admissionTier: 'status' }) expect(mocks.pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined) expect(mocks.getRuntimeGitUpstreamStatus).toHaveBeenCalledWith('id:wt-1') expect(mocks.bulkStageRuntimeGitPaths).toHaveBeenCalledWith('id:wt-1', ['a.ts', 'b.ts']) diff --git a/src/main/source-control/hosted-review-git-options.ts b/src/main/source-control/hosted-review-git-options.ts index eb8dc63f33c..060c116bcb2 100644 --- a/src/main/source-control/hosted-review-git-options.ts +++ b/src/main/source-control/hosted-review-git-options.ts @@ -1,5 +1,8 @@ +import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' + export type HostedReviewLocalGitOptions = { wslDistro?: string + admissionTier?: GitAdmissionTier } export type HostedReviewExecutionOptions = { @@ -14,7 +17,11 @@ export function getHostedReviewLocalGitOptions( options: HostedReviewExecutionOptions = {} ): HostedReviewLocalGitOptions { const wslDistro = options.localGitExecOptions?.wslDistro - return wslDistro ? { wslDistro } : {} + const admissionTier = options.localGitExecOptions?.admissionTier + return { + ...(wslDistro ? { wslDistro } : {}), + ...(admissionTier ? { admissionTier } : {}) + } } export function hasHostedReviewLocalGitOptions( diff --git a/src/main/source-control/pull-request-linked-issue.ts b/src/main/source-control/pull-request-linked-issue.ts index 94ea1432473..90c61930d91 100644 --- a/src/main/source-control/pull-request-linked-issue.ts +++ b/src/main/source-control/pull-request-linked-issue.ts @@ -2,6 +2,7 @@ import type { HostedReviewProvider } from '../../shared/hosted-review' import type { PullRequestLinkedIssue } from '../../shared/pull-request-generation' import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' import type { WorkspaceLinkedItem } from '../../shared/worktree/types' +import type { GitRuntimeOptions } from '../git/git-runtime-options' import { getIssue as getGitHubIssue } from '../github/issues' import { getIssue as getGitLabIssue } from '../gitlab/issues' @@ -11,7 +12,7 @@ export type PullRequestLinkedIssueMeta = { linkedWorkItem?: WorkspaceLinkedItem | null } -type LocalGitOptions = { wslDistro?: string } +type LocalGitOptions = Pick function inferIssueProvider( meta: PullRequestLinkedIssueMeta, diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index b5c4f77d2c8..d24cde4ea13 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -90,6 +90,9 @@ export async function getRepoDefaultBranchName( : gitExecFileAsync(argv, { cwd: repoPath, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier + ? { admissionTier: localGitOptions.admissionTier } + : {}), timeout: timeoutMs }) }) diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts index b71234d2d0c..d4e13a20e55 100644 --- a/src/preload/api/git-inspection-api.ts +++ b/src/preload/api/git-inspection-api.ts @@ -20,7 +20,9 @@ export type GitInspectionApi = { status: (args: { worktreePath: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' includeIgnored?: boolean + includeLineStats?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean /** Merge-base OID to measure the branch line total against; omit to skip the work. */ @@ -66,6 +68,7 @@ export type GitInspectionApi = { worktreePath: string baseRef: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' }) => Promise commitCompare: (args: { worktreePath: string diff --git a/src/preload/api/github-pull-request-api.ts b/src/preload/api/github-pull-request-api.ts index 5e044286b95..68de9ac2d31 100644 --- a/src/preload/api/github-pull-request-api.ts +++ b/src/preload/api/github-pull-request-api.ts @@ -44,7 +44,10 @@ export type GithubPullRequestApi = { acceptMergedFallbackPR?: boolean currentHeadOid?: string | null }) => Promise - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise + refreshPRNow: (args: { + candidate: GitHubPRRefreshCandidate + reason?: GitHubPRRefreshReason + }) => Promise enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c422ade23f..abd21173c70 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1466,8 +1466,10 @@ const api = { currentHeadOid?: string | null }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => - ipcRenderer.invoke('gh:refreshPRNow', args), + refreshPRNow: (args: { + candidate: GitHubPRRefreshCandidate + reason?: GitHubPRRefreshReason + }): Promise => ipcRenderer.invoke('gh:refreshPRNow', args), enqueuePRRefresh: (args: { candidate: GitHubPRRefreshCandidate @@ -3612,7 +3614,9 @@ const api = { status: (args: { worktreePath: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' includeIgnored?: boolean + includeLineStats?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean branchLineTotalMergeBase?: string @@ -3663,6 +3667,7 @@ const api = { worktreePath: string baseRef: string connectionId?: string + admissionTier?: 'interactive' | 'status' | 'background' }): Promise => ipcRenderer.invoke('git:branchCompare', args), commitCompare: (args: { worktreePath: string diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 2207a982dc7..b8e379ed262 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -209,6 +209,37 @@ describe('getStatusOp', () => { expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2) }) + it('omits line stats without overwriting the reusable line-stats cache', async () => { + const statusOutput = `${buildBranchStatusOutput('head-skip', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: statusOutput, stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const withStats = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir + }) + const withoutStats = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + includeLineStats: false + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + reuseLineStats: true + }) + + expect(withoutStats.entries).toEqual( + withStats.entries.map(({ added: _added, removed: _removed, ...entry }) => entry) + ) + expect(reused.entries).toEqual(withStats.entries) + expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(1) + }) + it('forwards the request abort signal to status and numstat subprocesses', async () => { const controller = new AbortController() const git = vi.fn(async (args) => { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index f87311c2cb3..6ae1baee2af 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -83,7 +83,8 @@ export async function getStatusOp( }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` - const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) + const lineStatsWriteToken = + params.includeLineStats === false ? null : beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( @@ -197,7 +198,7 @@ export async function getStatusOp( } // Why: skip numstat after the limit to avoid reintroducing its cost. - if (!didHitLimit) { + if (!didHitLimit && lineStatsWriteToken !== null) { const branchLineTotalInput = buildBranchLineTotalInput( git, worktreePath, @@ -218,7 +219,7 @@ export async function getStatusOp( recompute: () => attachLineStats(git, worktreePath, entries, options.signal), ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) })) - } else { + } else if (lineStatsWriteToken !== null) { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx index c306998ad7e..13f804f2270 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-conflict-refresh.tsx @@ -58,7 +58,8 @@ export function useChecksPanelConflictRefresh(model: ChecksPanelConflictRefreshI repoId: repo.id, worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, - fallbackPRNumber: fallbackGitHubPRNumber ?? pr.number + fallbackPRNumber: fallbackGitHubPRNumber ?? pr.number, + reason: 'active' }).finally(() => { // Why: fetchPRForBranch can rerun this effect; only the current key clears the spinner so stale requests don't race newer branches. if (conflictSummaryRefreshKeyRef.current === refreshKey) { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx index 2665473f605..3ba2dbf7e3e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx @@ -204,8 +204,6 @@ export function useChecksPanelControllerState() { const asyncResultKeyRef = useRef('') const refreshRequestKeyRef = useRef(null) const refreshContextKeyRef = useRef(null) - const gitStatusSnapshotInFlightContextRef = useRef(null) - const gitStatusSnapshotRerunContextRef = useRef(null) const gitStatusSnapshotRetryTimerRef = useRef | null>(null) const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null @@ -374,8 +372,6 @@ export function useChecksPanelControllerState() { asyncResultKeyRef, refreshRequestKeyRef, refreshContextKeyRef, - gitStatusSnapshotInFlightContextRef, - gitStatusSnapshotRerunContextRef, gitStatusSnapshotRetryTimerRef, gitIdentityDisplay, detachedHeadDisplay, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx index 8ac18593476..c451dcf4152 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx @@ -1,9 +1,6 @@ import { useEffect } from 'react' import { resolveChecksPanelPRRefreshRequest } from '../checks-panel-pr-refresh-request' -import { - shouldCoalesceChecksPanelGitStatusSnapshotRefresh, - shouldPollChecksPanelRuntimeSshStatus -} from '../checks-panel-git-status-snapshot' +import { shouldPollChecksPanelRuntimeSshStatus } from '../checks-panel-git-status-snapshot' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelContextState } from './use-checks-panel-context-state' import type { ChecksPanelReviewState } from './use-checks-panel-review-state' @@ -17,10 +14,7 @@ type ChecksPanelForegroundEffectsInput = Pick< | 'enqueueGitHubPRRefresh' | 'fetchHostedReviewForBranch' | 'foregroundedUnrenderedReviewKeyRef' - | 'gitStatusSnapshotInFlightContextRef' - | 'gitStatusSnapshotRerunContextRef' | 'isPanelVisible' - | 'panelContextKeyRef' | 'panelVisibleSinceRef' | 'repo' | 'repoConnectionId' @@ -55,8 +49,6 @@ export function useChecksPanelForegroundEffects(model: ChecksPanelForegroundEffe fetchHostedReviewForBranch, foregroundReviewEvidenceKey, foregroundedUnrenderedReviewKeyRef, - gitStatusSnapshotInFlightContextRef, - gitStatusSnapshotRerunContextRef, isFolder, isGitHubReviewContext, isPanelVisible, @@ -65,7 +57,6 @@ export function useChecksPanelForegroundEffects(model: ChecksPanelForegroundEffe linkedGiteaPR, linkedGitLabMR, linkedPR, - panelContextKeyRef, panelVisibleSinceRef, prCachedHasPR, prFetchedAt, @@ -150,27 +141,10 @@ export function useChecksPanelForegroundEffects(model: ChecksPanelForegroundEffe skippedInitialRun = true return } - const currentContextKey = panelContextKeyRef.current - if ( - shouldCoalesceChecksPanelGitStatusSnapshotRefresh( - gitStatusSnapshotInFlightContextRef.current, - currentContextKey - ) - ) { - gitStatusSnapshotRerunContextRef.current = currentContextKey - return - } setGitStatusRefreshNonce((value) => value + 1) }, + jitterOnVisible: true, intervalMs: RUNTIME_SSH_STATUS_REFRESH_MS }) - }, [ - isPanelVisible, - repoConnectionId, - runtimeEnvironmentId, - gitStatusSnapshotInFlightContextRef, - gitStatusSnapshotRerunContextRef, - setGitStatusRefreshNonce, - panelContextKeyRef - ]) + }, [isPanelVisible, repoConnectionId, runtimeEnvironmentId, setGitStatusRefreshNonce]) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx new file mode 100644 index 00000000000..231f835619d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import type { Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getRuntimeGitStatus: vi.fn(), + getRuntimeGitUpstreamStatus: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => mocks) + +import { useChecksPanelGitStatusEffects } from './use-checks-panel-git-status-effects' +import { deferred, flush, mountProbe, unmountProbes } from '../source-control-hook-test-harness' + +const retryTimerRef = { current: null as ReturnType | null } +const panelContextKeyRef = { current: 'context-A' } +const setGitStatusSnapshot = vi.fn() +const setGitStatusProbeErrorContextKey = vi.fn() +const setGitStatusRefreshNonce = vi.fn() +const updateWorktreeGitIdentity = vi.fn() + +function Probe({ + nonce, + contextKey = 'context-A', + isPanelVisible = true, + repoConnectionId = null, + sshConnectionStatus, + worktreeId = 'worktree-A', + worktreePath = '/repo' +}: { + nonce: number + contextKey?: string + isPanelVisible?: boolean + repoConnectionId?: string | null + sshConnectionStatus?: 'connected' | 'connecting' | 'disconnected' + worktreeId?: string + worktreePath?: string +}): null { + panelContextKeyRef.current = contextKey + useChecksPanelGitStatusEffects({ + activeConnectionId: repoConnectionId, + activeWorktreeId: worktreeId, + activeWorktreePath: worktreePath, + activeWorktreePushTarget: null, + branch: 'feature', + eligibilityHeadOidRef: { current: null }, + eligibilityRefreshNonce: 0, + getHostedReviewCreationEligibility: vi.fn(), + gitStatusInvalidation: 0, + gitStatusReadyForPanelContext: false, + gitStatusRefreshNonce: nonce, + gitStatusSnapshotRetryTimerRef: retryTimerRef, + hasUncommittedChanges: false, + hostedReviewCreationRequestKey: 'eligibility-A', + isFolder: false, + isPanelVisible, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGiteaPR: null, + linkedGitLabMR: null, + linkedPR: null, + fallbackGitHubPRNumber: null, + localExecutionScope: 'host', + ownerSettings: null, + panelContextKey: contextKey, + panelContextKeyRef, + remoteStatus: undefined, + remoteStatusInvalidation: 0, + repo: { id: 'repo-A', path: '/repo', connectionId: repoConnectionId, worktreeBaseRef: 'main' }, + repoConnectionId, + runtimeEnvironmentId: null, + setGitStatusProbeErrorContextKey, + setGitStatusRefreshNonce, + setGitStatusSnapshot, + setHostedReviewCreationSnapshot: vi.fn(), + sshConnectionStatus, + updateWorktreeGitIdentity + } as never) + return null +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + retryTimerRef.current = null + panelContextKeyRef.current = 'context-A' + mocks.getRuntimeGitStatus.mockReset() + mocks.getRuntimeGitUpstreamStatus.mockReset() + setGitStatusSnapshot.mockReset() + setGitStatusProbeErrorContextKey.mockReset() + setGitStatusRefreshNonce.mockReset() + updateWorktreeGitIdentity.mockReset() +}) + +afterEach(() => { + unmountProbes() + vi.useRealTimers() +}) + +describe('useChecksPanelGitStatusEffects poll runner', () => { + it('coalesces M nonce ticks into one trailing run after the slowTaskBackoff gap', async () => { + const first = deferred<{ + entries: never[] + head: string + branch: string + upstreamStatus: { hasUpstream: boolean; ahead: number; behind: number } + }>() + const status = { + entries: [], + head: 'head-A', + branch: 'feature', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } + mocks.getRuntimeGitStatus.mockReturnValueOnce(first.promise).mockResolvedValue(status) + const root: Root = await mountProbe() + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + for (let tick = 1; tick <= 5; tick += 1) { + await act(async () => { + root.render() + }) + } + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + first.resolve(status) + }) + await flush() + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(9_999) + }) + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2) + }) + + it('does not carry a slow worktree backoff into the next panel context', async () => { + const first = deferred<{ + entries: never[] + head: string + branch: string + upstreamStatus: { hasUpstream: boolean; ahead: number; behind: number } + }>() + const status = { + entries: [], + head: 'head-A', + branch: 'feature', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } + mocks.getRuntimeGitStatus.mockReturnValueOnce(first.promise).mockResolvedValue(status) + const root: Root = await mountProbe() + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + first.resolve(status) + }) + await flush() + + await act(async () => { + root.render( + + ) + }) + await flush() + + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2) + expect(mocks.getRuntimeGitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ worktreeId: 'worktree-B', worktreePath: '/repo-b' }), + { admissionTier: 'status' } + ) + }) + + it('does not carry a discarded hidden run backoff into the reopened panel', async () => { + const first = deferred<{ + entries: never[] + head: string + branch: string + upstreamStatus: { hasUpstream: boolean; ahead: number; behind: number } + }>() + const status = { + entries: [], + head: 'head-A', + branch: 'feature', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } + mocks.getRuntimeGitStatus.mockReturnValueOnce(first.promise).mockResolvedValue(status) + const root: Root = await mountProbe() + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + root.render() + }) + first.resolve(status) + await flush() + + await act(async () => { + root.render() + }) + await flush() + + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2) + }) + + it('does not carry a discarded disconnected run backoff through SSH reconnect', async () => { + const first = deferred<{ + entries: never[] + head: string + branch: string + upstreamStatus: { hasUpstream: boolean; ahead: number; behind: number } + }>() + const status = { + entries: [], + head: 'head-A', + branch: 'feature', + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } + } + mocks.getRuntimeGitStatus.mockReturnValueOnce(first.promise).mockResolvedValue(status) + const root: Root = await mountProbe( + + ) + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + root.render() + }) + first.resolve(status) + await flush() + + await act(async () => { + root.render() + }) + await flush() + + expect(mocks.getRuntimeGitStatus).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx index fa4eca45608..ba362915ddd 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx @@ -1,11 +1,11 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' import { buildChecksPanelEligibilityGitFingerprint, shouldClearChecksPanelGitStatusSnapshot, - shouldCoalesceChecksPanelGitStatusSnapshotRefresh, shouldCommitChecksPanelGitStatusSnapshot } from '../checks-panel-git-status-snapshot' +import { createCoalescedPollRunner, type CoalescedPollRunner } from '../coalesced-poll-runner' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelReviewState } from './use-checks-panel-review-state' import type { ChecksPanelContextState } from './use-checks-panel-context-state' @@ -21,8 +21,6 @@ type ChecksPanelGitStatusEffectsInput = Pick< | 'getHostedReviewCreationEligibility' | 'gitStatusInvalidation' | 'gitStatusRefreshNonce' - | 'gitStatusSnapshotInFlightContextRef' - | 'gitStatusSnapshotRerunContextRef' | 'gitStatusSnapshotRetryTimerRef' | 'isPanelVisible' | 'localExecutionScope' @@ -60,6 +58,12 @@ type ChecksPanelGitStatusEffectsInput = Pick< > const GIT_STATUS_FAILURE_RETRY_MS = 3000 +const CHECKS_PANEL_GIT_STATUS_MIN_INTERVAL_MS = 3000 +const CHECKS_PANEL_GIT_STATUS_SLOW_BACKOFF = { + idleMultiplier: 1, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 +} export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffectsInput) { const { @@ -74,8 +78,6 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect gitStatusInvalidation, gitStatusReadyForPanelContext, gitStatusRefreshNonce, - gitStatusSnapshotInFlightContextRef, - gitStatusSnapshotRerunContextRef, gitStatusSnapshotRetryTimerRef, hasUncommittedChanges, hostedReviewCreationRequestKey, @@ -103,6 +105,36 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect sshConnectionStatus, updateWorktreeGitIdentity } = model + const gitStatusRequestRef = useRef<(() => Promise) | null>(null) + const gitStatusPollRunnerRef = useRef(null) + const gitStatusPollingReady = Boolean( + repo && + !isFolder && + branch && + isPanelVisible && + activeWorktreeId && + activeWorktreePath && + (runtimeEnvironmentId || !repoConnectionId || sshConnectionStatus === 'connected') + ) + + useEffect(() => { + const runner = createCoalescedPollRunner( + () => gitStatusRequestRef.current?.() ?? Promise.resolve(), + { + minIntervalMs: CHECKS_PANEL_GIT_STATUS_MIN_INTERVAL_MS, + slowTaskBackoff: CHECKS_PANEL_GIT_STATUS_SLOW_BACKOFF + } + ) + gitStatusPollRunnerRef.current = runner + return () => { + gitStatusRequestRef.current = null + runner.dispose() + if (gitStatusPollRunnerRef.current === runner) { + gitStatusPollRunnerRef.current = null + } + } + }, [gitStatusPollingReady, panelContextKey]) + useEffect(() => { if ( !repo || @@ -117,24 +149,13 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect clearTimeout(gitStatusSnapshotRetryTimerRef.current) gitStatusSnapshotRetryTimerRef.current = null } + gitStatusRequestRef.current = null // Why: hiding the panel or losing SSH should stop new work, not erase same-context Create PR eligibility that can still be retried. return } let stale = false const requestContextKey = panelContextKey const connectionId = activeConnectionId ?? undefined - if ( - shouldCoalesceChecksPanelGitStatusSnapshotRefresh( - gitStatusSnapshotInFlightContextRef.current, - requestContextKey - ) - ) { - gitStatusSnapshotRerunContextRef.current = requestContextKey - return () => { - stale = true - } - } - gitStatusSnapshotInFlightContextRef.current = requestContextKey // Why: global status maps are keyed only by worktree; use their changes as invalidation signals, then fetch a local snapshot. if (gitStatusSnapshotRetryTimerRef.current) { clearTimeout(gitStatusSnapshotRetryTimerRef.current) @@ -149,32 +170,30 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect worktreePath: activeWorktreePath, connectionId } - void (async () => { - const status = await getRuntimeGitStatus(context) - if ( - !stale && - shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) - ) { - // Why: the Checks tab can be the only visible git surface; commit branch identity before branch-scoped upstream refresh can fail. - updateWorktreeGitIdentity(activeWorktreeId, { - head: status.head, - branch: status.branch ?? (status.head ? null : undefined) - }) - } - let freshRemoteStatus = status.upstreamStatus - if (activeWorktreePushTarget) { - freshRemoteStatus = await getRuntimeGitUpstreamStatus(context, activeWorktreePushTarget) - } else if ( - !freshRemoteStatus || - (freshRemoteStatus.ahead > 0 && - freshRemoteStatus.behind > 0 && - freshRemoteStatus.behindCommitsArePatchEquivalent === undefined) - ) { - freshRemoteStatus = await getRuntimeGitUpstreamStatus(context) - } - return { status, remoteStatus: freshRemoteStatus } - })() - .then(({ status, remoteStatus }) => { + const runRequest = async (): Promise => { + try { + const status = await getRuntimeGitStatus(context, { admissionTier: 'status' }) + if ( + !stale && + shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) + ) { + // Why: the Checks tab can be the only visible git surface; commit branch identity before branch-scoped upstream refresh can fail. + updateWorktreeGitIdentity(activeWorktreeId, { + head: status.head, + branch: status.branch ?? (status.head ? null : undefined) + }) + } + let freshRemoteStatus = status.upstreamStatus + if (activeWorktreePushTarget) { + freshRemoteStatus = await getRuntimeGitUpstreamStatus(context, activeWorktreePushTarget) + } else if ( + !freshRemoteStatus || + (freshRemoteStatus.ahead > 0 && + freshRemoteStatus.behind > 0 && + freshRemoteStatus.behindCommitsArePatchEquivalent === undefined) + ) { + freshRemoteStatus = await getRuntimeGitUpstreamStatus(context) + } if ( !stale && shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) @@ -182,7 +201,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect setGitStatusSnapshot({ contextKey: requestContextKey, hasUncommittedChanges: status.entries.length > 0, - remoteStatus, + remoteStatus: freshRemoteStatus, gitIdentity: { head: status.head, branch: status.branch ?? (status.head ? null : undefined) @@ -191,8 +210,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect // A fresh probe succeeded, so this context is no longer in the "could not check branch status" state. setGitStatusProbeErrorContextKey((key) => (key === requestContextKey ? null : key)) } - }) - .catch((error) => { + } catch (error) { console.warn('[ChecksPanel] git status refresh before eligibility failed', error) if (!stale) { // Why: transient SSH/runtime flakes shouldn't hide an already-valid Create PR state for this branch; retry while visible. @@ -217,22 +235,15 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect } }, GIT_STATUS_FAILURE_RETRY_MS) } - }) - .finally(() => { - if (gitStatusSnapshotInFlightContextRef.current === requestContextKey) { - gitStatusSnapshotInFlightContextRef.current = null - } - if (gitStatusSnapshotRerunContextRef.current === requestContextKey) { - gitStatusSnapshotRerunContextRef.current = null - if ( - shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) - ) { - setGitStatusRefreshNonce((value) => value + 1) - } - } - }) + } + } + gitStatusRequestRef.current = runRequest + gitStatusPollRunnerRef.current?.run() return () => { stale = true + if (gitStatusRequestRef.current === runRequest) { + gitStatusRequestRef.current = null + } if (gitStatusSnapshotRetryTimerRef.current) { clearTimeout(gitStatusSnapshotRetryTimerRef.current) gitStatusSnapshotRetryTimerRef.current = null @@ -259,9 +270,7 @@ export function useChecksPanelGitStatusEffects(model: ChecksPanelGitStatusEffect setGitStatusProbeErrorContextKey, setGitStatusSnapshot, gitStatusSnapshotRetryTimerRef, - gitStatusSnapshotRerunContextRef, setGitStatusRefreshNonce, - gitStatusSnapshotInFlightContextRef, panelContextKeyRef ]) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx index 959ea4468ec..94c07d7171d 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx @@ -112,5 +112,13 @@ describe('useChecksPanelManualRefresh ordering', () => { 'comments-loading:false', 'refreshing:false' ]) + expect(input.fetchPRForBranch).toHaveBeenCalledWith('/workspace/repo', 'main', { + force: true, + repoId: 'repo-1', + worktreeId: undefined, + linkedPRNumber: null, + fallbackPRNumber: null, + reason: 'manual' + }) }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 25ab151a19a..796140405ef 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -117,7 +117,9 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput worktreePath: activeWorktreePath, connectionId: activeConnectionId ?? undefined } - const status = await getRuntimeGitStatus(statusContext) + const status = await getRuntimeGitStatus(statusContext, { + admissionTier: 'interactive' + }) const observedBranch = status.branch ?? (status.head ? null : undefined) updateWorktreeGitIdentity(activeWorktreeId, { head: status.head, @@ -172,6 +174,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput repoPath: repo.path, repoId: repo.id, branch, + admissionTier: 'interactive', linkedGitHubPR: linkedPR, fallbackGitHubPR: fallbackGitHubPRNumber, linkedGitLabMR, @@ -212,7 +215,8 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput repoId: repo.id, worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, - fallbackPRNumber: fallbackGitHubPRNumber + fallbackPRNumber: fallbackGitHubPRNumber, + reason: 'manual' }) } finally { if (startedPRRefreshToken) { @@ -226,6 +230,7 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput repoPath: repo.path, repoId: repo.id, branch, + admissionTier: 'interactive', linkedGitHubPR: linkedPR, fallbackGitHubPR: refreshedPR?.number ?? fallbackGitHubPRNumber, linkedGitLabMR, diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts index bff4e78bbdc..d5f7a5ed120 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts @@ -4,12 +4,15 @@ import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced function deferred(): { promise: Promise resolve: () => void + reject: (error: Error) => void } { let resolve: () => void = () => {} - const promise = new Promise((r) => { + let reject: (error: Error) => void = () => {} + const promise = new Promise((r, j) => { resolve = r + reject = j }) - return { promise, resolve } + return { promise, resolve, reject } } async function flushMicrotasks(): Promise { @@ -167,6 +170,28 @@ describe('createCoalescedPollRunner', () => { vi.useRealTimers() }) + it('paces a GitCommandTimeoutError from its full timeout duration', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + runner.run() + runner.run() + await vi.advanceTimersByTimeAsync(120_000) + const timeoutError = new Error('git timed out.') + timeoutError.name = 'GitCommandTimeoutError' + calls[0]?.reject(timeoutError) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(299_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + it('keeps minIntervalMs as the gap for fast tasks', async () => { vi.useFakeTimers() const { runner, task, calls } = makeSlowRunner() diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts index 4727ebd173b..2bb1f45d6ff 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -61,6 +61,7 @@ describe('branch line total request gate on git status refreshes', () => { // Why: no OID on the request is the whole performance contract — the host // runs no ranged diff, so a background worktree costs nothing. expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'status', worktreePath: '/repo', connectionId: 'ssh-1' }) @@ -78,6 +79,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'status', worktreePath: '/repo', connectionId: undefined }) @@ -94,6 +96,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'status', worktreePath: '/other-repo', connectionId: undefined }) @@ -113,6 +116,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'status', worktreePath: '/repo', connectionId: 'ssh-1', branchLineTotalMergeBase: MERGE_BASE @@ -131,6 +135,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'status', worktreePath: '/repo', connectionId: undefined, reuseLineStats: true, @@ -175,11 +180,13 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenNthCalledWith(1, { + admissionTier: 'status', worktreePath: '/repo', connectionId: undefined, branchLineTotalMergeBase: MERGE_BASE }) expect(gitStatus).toHaveBeenNthCalledWith(2, { + admissionTier: 'status', worktreePath: '/repo', connectionId: undefined, branchLineTotalMergeBase: 'rebased-merge-base' @@ -196,6 +203,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'interactive', worktreePath: '/repo', connectionId: undefined, bypassEffectiveUpstreamNegativeCache: true @@ -215,6 +223,7 @@ describe('branch line total request gate on git status refreshes', () => { }) expect(gitStatus).toHaveBeenCalledWith({ + admissionTier: 'interactive', worktreePath: '/repo', connectionId: undefined, bypassEffectiveUpstreamNegativeCache: true, diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts index 1e00ac163b5..70ed74bad1e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts @@ -29,7 +29,7 @@ function createScheduler( activityDebounceMs: 125, activityMinGapMs: 3000, slowTaskBackoff: { - idleMultiplier: 5, + idleMultiplier: 1, changeSignalMultiplier: 1, maxIntervalMs: 5 * 60_000 }, @@ -176,11 +176,11 @@ describe('createGitStatusRefreshScheduler', () => { await vi.advanceTimersByTimeAsync(1) expect(task).toHaveBeenCalledTimes(2) - // The second scan is also slow; the next safety waits max(60s, 5x) = 150s. + // The second scan is also slow; the next safety waits max(60s, 1x) = 60s. await vi.advanceTimersByTimeAsync(30_000) calls[1]?.resolve() await flushMicrotasks() - await vi.advanceTimersByTimeAsync(149_999) + await vi.advanceTimersByTimeAsync(59_999) expect(task).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(1) expect(task).toHaveBeenCalledTimes(3) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index dd03ae54f87..bfe42b97513 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -63,7 +63,8 @@ describe('refreshGitStatusForWorktree', () => { expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: 'ssh-1' + connectionId: 'ssh-1', + admissionTier: 'status' }) expect(deps.setGitStatus).toHaveBeenCalledWith('wt-1', status) expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledWith('wt-1', { @@ -150,7 +151,8 @@ describe('refreshGitStatusForWorktree', () => { expect(gitStatus).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + admissionTier: 'status' }) expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) }) @@ -178,11 +180,13 @@ describe('refreshGitStatusForWorktree', () => { expect(gitStatus).toHaveBeenNthCalledWith(1, { worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + admissionTier: 'status' }) expect(gitStatus).toHaveBeenNthCalledWith(2, { worktreePath: '/repo', connectionId: undefined, + admissionTier: 'interactive', bypassEffectiveUpstreamNegativeCache: true }) }) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index ec654b2c175..ade0f1d7633 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -112,6 +112,7 @@ export async function refreshGitStatusForWorktree({ pushTarget?: GitPushTarget deps: GitStatusRefreshDeps request?: { + admissionTier?: 'interactive' | 'status' | 'background' reuseLineStats?: boolean signal?: AbortSignal shouldApply?: () => boolean @@ -131,6 +132,7 @@ export async function refreshGitStatusForWorktree({ connectionId }, { + admissionTier: request?.admissionTier ?? 'status', ...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}), ...(request?.signal ? { signal: request.signal } : {}), ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) @@ -256,6 +258,7 @@ export async function refreshGitStatusForWorktreeStrict({ connectionId }, { + admissionTier: 'interactive', // Why: strict refreshes are user-triggered reconciliation and must not reuse // automatic polling's no-upstream backoff window. bypassEffectiveUpstreamNegativeCache: true, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts index 20edb394d81..017b18dff55 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-created.ts @@ -109,7 +109,8 @@ export function useSourceControlHostedReviewCreated({ force: true, repoId, worktreeId: worktreeId ?? undefined, - linkedPRNumber: result.number + linkedPRNumber: result.number, + reason: 'post-push' }) ]) } catch { diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts index 74601a3c0d5..3979ddf8e35 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-branch-compare.ts @@ -11,9 +11,14 @@ import { type BranchCompareRemoteStatusSnapshot, type BranchCompareStatusHeadSnapshot } from './compare-summary' +import { slowTaskRequiredIdleMs } from '../../coalesced-poll-runner' -// Why: 30s poll — 5s churned git subprocesses in large repos; explicit commit/remote/manual/base-ref refreshes still run immediately. +// Why: 30s poll — slow runs idle for their own duration; explicit commit/remote/manual/base-ref refreshes still run immediately. export const BRANCH_REFRESH_INTERVAL_MS = 30_000 +const BRANCH_REFRESH_IDLE_MULTIPLIER = 1 +const BRANCH_REFRESH_MAX_INTERVAL_MS = 5 * 60_000 + +type BranchCompareRefreshKind = 'immediate' | 'interval' export function useSourceControlBranchCompare({ activeRepoSettings, @@ -43,99 +48,174 @@ export function useSourceControlBranchCompare({ const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult) const clearGitBranchCompare = useAppStore((s) => s.clearGitBranchCompare) const branchCompareInFlightRef = useRef(false) - const branchCompareRerunRef = useRef(false) + const branchCompareRerunRef = useRef(null) const branchCompareRunPromiseRef = useRef | null>(null) const refreshBranchCompareRef = useRef<() => Promise>(async () => {}) + const startBranchCompareRef = useRef<(kind: BranchCompareRefreshKind) => Promise>( + async () => {} + ) + const branchComparePollTimerRef = useRef | null>(null) + const branchComparePollEnabledRef = useRef(false) + const branchCompareLastRunEndedAtRef = useRef(-Infinity) + const branchCompareLastRunDurationRef = useRef(0) const branchCompareStatusHeadRef = useRef(null) const branchCompareRemoteStatusRef = useRef(null) - const runBranchCompare = useCallback(async () => { - if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) { + const runBranchCompare = useCallback( + async (kind: BranchCompareRefreshKind) => { + if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) { + return + } + const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}` + const existingSummary = + useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId] + // Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker. + const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef + const shouldResetToLoading = !existingSummary || baseRefChanged + if (shouldResetToLoading) { + beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef) + } else { + beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { + preserveExistingSummary: true + }) + } + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const result = await getRuntimeGitBranchCompare( + { + // Why: route the branch compare by the repo OWNER host, not the focused runtime. + settings: activeRepoSettings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + compareBaseRef, + kind === 'interval' ? 'background' : 'interactive' + ) + setGitBranchCompareResult(activeWorktreeId, requestKey, result) + } catch (error) { + setGitBranchCompareResult(activeWorktreeId, requestKey, { + summary: { + baseRef: compareBaseRef, + baseOid: null, + compareRef: branchName, + headOid: null, + mergeBase: null, + changedFiles: 0, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Branch compare failed' + }, + entries: [] + }) + } + }, + [ + activeRepoSettings, + activeWorktreeId, + beginGitBranchCompareRequest, + branchName, + compareBaseRef, + isFolder, + setGitBranchCompareResult, + worktreePath + ] + ) + + const clearBranchComparePollTimer = useCallback((): void => { + if (branchComparePollTimerRef.current !== null) { + clearTimeout(branchComparePollTimerRef.current) + branchComparePollTimerRef.current = null + } + }, []) + + const scheduleBranchComparePoll = useCallback((): void => { + if (!branchComparePollEnabledRef.current || branchComparePollTimerRef.current !== null) { return } - const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}` - const existingSummary = - useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId] - // Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker. - const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef - const shouldResetToLoading = !existingSummary || baseRefChanged - if (shouldResetToLoading) { - beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef) - } else { - beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, { - preserveExistingSummary: true - }) + const now = Date.now() + const requiredIdleMs = slowTaskRequiredIdleMs( + branchCompareLastRunDurationRef.current, + BRANCH_REFRESH_IDLE_MULTIPLIER, + BRANCH_REFRESH_INTERVAL_MS, + BRANCH_REFRESH_MAX_INTERVAL_MS + ) + const allowedAt = branchCompareLastRunEndedAtRef.current + requiredIdleMs + if (now >= allowedAt) { + void startBranchCompareRef.current('interval') + return } - try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - const result = await getRuntimeGitBranchCompare( - { - // Why: route the branch compare by the repo OWNER host, not the focused runtime. - settings: activeRepoSettings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - compareBaseRef - ) - setGitBranchCompareResult(activeWorktreeId, requestKey, result) - } catch (error) { - setGitBranchCompareResult(activeWorktreeId, requestKey, { - summary: { - baseRef: compareBaseRef, - baseOid: null, - compareRef: branchName, - headOid: null, - mergeBase: null, - changedFiles: 0, - status: 'error', - errorMessage: error instanceof Error ? error.message : 'Branch compare failed' - }, - entries: [] - }) - } - }, [ - activeRepoSettings, - activeWorktreeId, - beginGitBranchCompareRequest, - branchName, - compareBaseRef, - isFolder, - setGitBranchCompareResult, - worktreePath - ]) + branchComparePollTimerRef.current = setTimeout(() => { + branchComparePollTimerRef.current = null + if (branchComparePollEnabledRef.current) { + void startBranchCompareRef.current('interval') + } + }, allowedAt - now) + }, []) - const refreshBranchCompare = useCallback(async () => { - if (branchCompareInFlightRef.current) { - branchCompareRerunRef.current = true - return branchCompareRunPromiseRef.current ?? undefined - } - branchCompareInFlightRef.current = true - const runPromise = (async (): Promise => { - // Why: keep one branch-compare chain in flight and collapse skipped ticks into one trailing refresh instead of stacking git subprocesses. - try { - await runBranchCompare() - } finally { - branchCompareInFlightRef.current = false - if (branchCompareRerunRef.current) { - branchCompareRerunRef.current = false - await refreshBranchCompareRef.current() + const startBranchCompare = useCallback( + async (kind: BranchCompareRefreshKind) => { + if (kind === 'immediate') { + clearBranchComparePollTimer() + } + if (branchCompareInFlightRef.current) { + if (kind === 'immediate' || branchCompareRerunRef.current === null) { + branchCompareRerunRef.current = kind + } + return branchCompareRunPromiseRef.current ?? undefined + } + if (kind === 'interval') { + const requiredIdleMs = slowTaskRequiredIdleMs( + branchCompareLastRunDurationRef.current, + BRANCH_REFRESH_IDLE_MULTIPLIER, + BRANCH_REFRESH_INTERVAL_MS, + BRANCH_REFRESH_MAX_INTERVAL_MS + ) + if (Date.now() < branchCompareLastRunEndedAtRef.current + requiredIdleMs) { + scheduleBranchComparePoll() + return } } - })() - branchCompareRunPromiseRef.current = runPromise - try { - await runPromise - } finally { - if (branchCompareRunPromiseRef.current === runPromise) { - branchCompareRunPromiseRef.current = null + clearBranchComparePollTimer() + branchCompareInFlightRef.current = true + const startedAt = Date.now() + const runPromise = (async (): Promise => { + // Why: keep one branch-compare chain in flight and collapse skipped ticks into one trailing refresh instead of stacking git subprocesses. + try { + await runBranchCompare(kind) + } finally { + const endedAt = Date.now() + branchCompareLastRunEndedAtRef.current = endedAt + branchCompareLastRunDurationRef.current = endedAt - startedAt + branchCompareInFlightRef.current = false + const rerunKind = branchCompareRerunRef.current + branchCompareRerunRef.current = null + if (rerunKind === 'immediate') { + await refreshBranchCompareRef.current() + } else if (rerunKind === 'interval') { + scheduleBranchComparePoll() + } + } + })() + branchCompareRunPromiseRef.current = runPromise + try { + await runPromise + } finally { + if (branchCompareRunPromiseRef.current === runPromise) { + branchCompareRunPromiseRef.current = null + } } - } - }, [runBranchCompare]) + }, + [clearBranchComparePollTimer, runBranchCompare, scheduleBranchComparePoll] + ) + const refreshBranchCompare = useCallback( + () => startBranchCompare('immediate'), + [startBranchCompare] + ) // Why: publish in an effect, not the render body — a discarded render must not install its callback. Declared first so the effects below see the fresh one. useEffect(() => { refreshBranchCompareRef.current = refreshBranchCompare - }, [refreshBranchCompare]) + startBranchCompareRef.current = startBranchCompare + }, [refreshBranchCompare, startBranchCompare]) useEffect(() => { if (!activeWorktreeId || !worktreePath || !isBranchVisible || !compareBaseRef || isFolder) { @@ -197,11 +277,25 @@ export function useSourceControlBranchCompare({ return } // Why: HEAD changes refresh branch compare immediately; keep a visible-window fallback for base/remote updates that don't move HEAD. - return installWindowVisibilityInterval({ - run: () => void refreshBranchCompareRef.current(), + branchComparePollEnabledRef.current = true + const stopInterval = installWindowVisibilityInterval({ + run: () => void startBranchCompareRef.current('interval'), + jitterOnVisible: true, intervalMs: BRANCH_REFRESH_INTERVAL_MS }) - }, [activeWorktreeId, compareBaseRef, isBranchVisible, isFolder, worktreePath]) + return () => { + branchComparePollEnabledRef.current = false + clearBranchComparePollTimer() + stopInterval() + } + }, [ + activeWorktreeId, + clearBranchComparePollTimer, + compareBaseRef, + isBranchVisible, + isFolder, + worktreePath + ]) useEffect(() => { // Why: when compare-base resolves to no base, drop the stale summary (gate on loaded upstream status to avoid flicker). diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts index 4686b01c750..46db77e99ab 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-status-refresh.ts @@ -65,7 +65,7 @@ export function useSourceControlStatusRefresh({ setUpstreamStatus, fetchUpstreamStatus }, - ...(signal ? { request: { signal } } : {}) + request: { admissionTier: 'interactive', ...(signal ? { signal } : {}) } }) }, [ diff --git a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx index 3a44177e439..ea32c80039b 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-source-control-branch-compare.test.tsx @@ -231,13 +231,18 @@ describe('useSourceControlBranchCompare scheduler', () => { expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(2) }) - it('collapses a poll tick that fires while a refresh is in flight', async () => { + it('coalesces M interval ticks into one trailing run after the slowTaskBackoff gap', async () => { vi.useFakeTimers() const first = deferred() mocks.getRuntimeGitBranchCompare.mockReturnValueOnce(first.promise) // Visible mounts run once immediately through the visibility interval. await mount({ isBranchVisible: true }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(1) + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith( + expect.objectContaining({ worktreeId: 'A' }), + 'origin/main', + 'background' + ) await act(async () => { vi.advanceTimersByTime(BRANCH_REFRESH_INTERVAL_MS * 3) @@ -250,8 +255,22 @@ describe('useSourceControlBranchCompare scheduler', () => { await Promise.resolve() await Promise.resolve() }) - // Three skipped ticks collapse into exactly one trailing run. + // Three skipped ticks collapse into one pending run, but a 90s task must + // idle for its own duration before the trailing run. + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(89_999) + }) + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(2) + expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith( + expect.objectContaining({ worktreeId: 'A' }), + 'origin/main', + 'background' + ) }) it('preserves the existing summary on a poll refresh but resets to loading on a base-ref change', async () => { @@ -306,7 +325,8 @@ describe('useSourceControlBranchCompare scheduler', () => { }) expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'A' }), - 'origin/dev' + 'origin/dev', + 'interactive' ) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index b1c6d3586d1..a6245c0f1f9 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -4,6 +4,11 @@ import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types import type { GitStatusResult } from '../../../../shared/git-status-types' import type { GitPushTarget } from '../../../../shared/worktree/types' import { DEFAULT_GIT_STATUS_LIMIT } from '../../../../shared/git-status-limit' +import { slowTaskRequiredIdleMs } from './coalesced-poll-runner' +import { + admissionTierForGitStatusRefreshReason, + SLOW_GIT_POLL_BACKOFF +} from './useGitStatusPolling' const worktree = { id: 'repo-1::/repo', repoId: 'repo-1', path: '/repo' } const repo = { id: 'repo-1', path: '/repo', kind: 'git', connectionId: null as string | null } @@ -151,6 +156,22 @@ async function usePollingOnce( } describe('useGitStatusPolling', () => { + it('paces the safety scheduler and stale-conflict fan-out by one run duration', () => { + expect( + slowTaskRequiredIdleMs( + 6_000, + SLOW_GIT_POLL_BACKOFF.idleMultiplier, + 3_000, + SLOW_GIT_POLL_BACKOFF.maxIntervalMs + ) + ).toBe(6_000) + }) + + it('keeps activity admission in status and safety admission in background', () => { + expect(admissionTierForGitStatusRefreshReason('activity')).toBe('status') + expect(admissionTierForGitStatusRefreshReason('safety')).toBe('background') + }) + beforeEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() @@ -158,7 +179,7 @@ describe('useGitStatusPolling', () => { }) it('uses upstream data from git status instead of spawning a separate upstream refresh', async () => { - const { state } = await usePollingOnce({ + const { state, gitStatus } = await usePollingOnce({ entries: [], conflictOperation: 'unknown', head: 'abc123', @@ -178,6 +199,7 @@ describe('useGitStatusPolling', () => { behind: 0 }) expect(state.fetchUpstreamStatus).not.toHaveBeenCalled() + expect(gitStatus).toHaveBeenCalledWith(expect.objectContaining({ admissionTier: 'status' })) }) it('falls back to the upstream IPC for legacy status payloads', async () => { @@ -258,6 +280,7 @@ describe('useGitStatusPolling', () => { ) expect(gitStatus).toHaveBeenCalledTimes(1) + expect(gitStatus).toHaveBeenCalledWith(expect.objectContaining({ admissionTier: 'status' })) expect(globalThis.setInterval).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 14212586823..ca0bef6be7e 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -30,12 +30,18 @@ const STATUS_ACTIVITY_DEBOUNCE_MS = 125 const STATUS_ACTIVITY_MIN_GAP_MS = 3000 // Why: status scans and remote conflict probes can take longer than their // timers; duration-aware spacing prevents a slow task from running nonstop. -const SLOW_GIT_POLL_BACKOFF = { - idleMultiplier: 5, +export const SLOW_GIT_POLL_BACKOFF = { + idleMultiplier: 1, changeSignalMultiplier: 1, maxIntervalMs: 5 * 60_000 } +export function admissionTierForGitStatusRefreshReason( + reason: GitStatusRefreshReason +): 'status' | 'background' { + return reason === 'safety' ? 'background' : 'status' +} + export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) @@ -143,6 +149,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { fetchUpstreamStatus }, request: { + admissionTier: admissionTierForGitStatusRefreshReason(request.reason), ...(request.reason === 'safety' ? { reuseLineStats: true } : {}), signal: request.signal, shouldApply: request.shouldApply, diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts index 27a5f100654..15f7cc04a98 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.host-context-boundary.test.ts @@ -14,7 +14,7 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('DeleteWorktreeDialog host-context boundaries', () => { it('preloads git status from the selected worktree owner instead of the focused host', () => { - const effect = sourceBetween(SOURCE, 'const targets = deleteTargets.filter(', 'return () => {') + const effect = sourceBetween(SOURCE, 'deleteTargets.filter(', 'return () => {') expect(effect).toContain('getSettingsForWorktreeRuntimeOwner') expect(effect).toContain('worktreesByRepo: useAppStore.getState().worktreesByRepo') @@ -28,7 +28,7 @@ describe('DeleteWorktreeDialog host-context boundaries', () => { expect(SOURCE).not.toContain('useAppStore((state) => state.gitStatusByWorktree)') expect(effect).toContain('useAppStore.getState().gitStatusByWorktree') expect(effect).toContain('const controller = new AbortController()') - expect(effect).toContain('{ signal: controller.signal }') + expect(effect).toContain('signal: controller.signal') expect(effect).toContain('controller.abort()') }) }) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 5dc53aa064a..f6656118f16 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -173,6 +173,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const gitStatusByWorktreeIdentity = useDeleteWorktreeStatusHydration({ isOpen, deleteTargets, + visibleTargets: worktrees, repoMap }) const dirtyChangeCountsByWorktreeId = useMemo(() => { diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts new file mode 100644 index 00000000000..b7e14dcaab9 --- /dev/null +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/worktree/types' +import { orderDeleteWorktreeStatusHydrationTargets } from './delete-worktree-dirty-change-counts' + +function worktree(id: string, hostId?: Worktree['hostId']): Worktree { + return { + id, + repoId: 'repo', + path: `/${id}`, + displayName: id, + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...(hostId ? { hostId } : {}) + } +} + +describe('delete-worktree status hydration ordering', () => { + it('orders the active target first, visible targets next, and descendants last', () => { + const targets = [ + worktree('descendant-a'), + worktree('visible-a'), + worktree('active', 'ssh:builder'), + worktree('visible-b'), + worktree('descendant-b') + ] + + expect( + orderDeleteWorktreeStatusHydrationTargets({ + targets, + visibleTargets: [targets[1], targets[3]], + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder' + }).map((target) => target.id) + ).toEqual(['active', 'visible-a', 'visible-b', 'descendant-a', 'descendant-b']) + }) +}) diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts index 6bfd2da03d4..88546ae6128 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-counts.ts @@ -1,9 +1,33 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' +import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import type { WorktreeDeleteState } from '../../store/slices/worktree-helpers' import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' + +export function orderDeleteWorktreeStatusHydrationTargets({ + targets, + visibleTargets, + activeWorktreeId, + activeExecutionHostId +}: { + targets: readonly Worktree[] + visibleTargets: readonly Worktree[] + activeWorktreeId: string | null + activeExecutionHostId: string | null +}): Worktree[] { + const visibleIdentities = new Set(visibleTargets.map(getWorktreeHostIdentity)) + return targets + .map((target, index) => { + const isActive = + target.id === activeWorktreeId && + (!activeExecutionHostId || (target.hostId ?? 'local') === activeExecutionHostId) + const rank = isActive ? 0 : visibleIdentities.has(getWorktreeHostIdentity(target)) ? 1 : 2 + return { target, index, rank } + }) + .sort((left, right) => left.rank - right.rank || left.index - right.index) + .map(({ target }) => target) +} import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' -import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' export function getDeleteWorktreeDirtyChangeCounts({ deleteTargets, diff --git a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts index 487584c6774..475f0f0d1de 100644 --- a/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts +++ b/src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts @@ -10,16 +10,19 @@ import type { GitStatusResult } from '../../../../shared/git-status-types' import { parseExecutionHostId } from '../../../../shared/execution-host' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy' +import { orderDeleteWorktreeStatusHydrationTargets } from './delete-worktree-dirty-change-counts' const EMPTY_STATUS_BY_IDENTITY = new Map() export function useDeleteWorktreeStatusHydration({ isOpen, deleteTargets, + visibleTargets, repoMap }: { isOpen: boolean deleteTargets: readonly Worktree[] + visibleTargets: readonly Worktree[] repoMap: ReadonlyMap }): ReadonlyMap { const repos = useAppStore((state) => state.repos) @@ -39,9 +42,15 @@ export function useDeleteWorktreeStatusHydration({ return } const gitStatusByWorktree = useAppStore.getState().gitStatusByWorktree - const targets = deleteTargets.filter( - (target) => !target.isMainWorktree && !isFolderWorkspaceDelete(repoMap, target) - ) + const currentState = useAppStore.getState() + const targets = orderDeleteWorktreeStatusHydrationTargets({ + targets: deleteTargets.filter( + (target) => !target.isMainWorktree && !isFolderWorkspaceDelete(repoMap, target) + ), + visibleTargets, + activeWorktreeId: currentState.activeWorktreeId, + activeExecutionHostId: currentState.activeWorkspaceExecutionHostId + }) const controller = new AbortController() for (const target of targets) { const identity = getWorktreeHostIdentity(target) @@ -72,7 +81,7 @@ export function useDeleteWorktreeStatusHydration({ ? (owner?.connectionId ?? undefined) : (getConnectionId(target.id) ?? undefined) }, - { signal: controller.signal } + { admissionTier: 'background', includeLineStats: false, signal: controller.signal } ) .then((status) => { if (!controller.signal.aborted && generationRef.current === generation) { @@ -86,7 +95,7 @@ export function useDeleteWorktreeStatusHydration({ return () => { controller.abort() } - }, [deleteTargets, generation, isOpen, repoMap, repos, settings]) + }, [deleteTargets, generation, isOpen, repoMap, repos, settings, visibleTargets]) return currentStatusByIdentity } diff --git a/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts index 77dfa0331c3..971c7bf0408 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts @@ -90,6 +90,7 @@ export function useWorktreeCardLifecycleEffects({ // Why: PRs created outside Orca (e.g. `gh pr create`) emit no renderer event; poll visible cards to discover them. return installWindowVisibilityInterval({ run: refreshHostedReview, + jitterOnVisible: true, intervalMs: HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS }) }, [ diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 3d3ff980051..6f36caca369 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -96,7 +96,6 @@ import { countWorkspaceSpaceActiveAgents, getLargestWorkspaceSpaceItemSize, getLargestWorkspaceSpaceRowSize, - getWorkspaceSpaceGitStatusRefreshCandidates, isWorkspaceSpaceRowReadyToDelete, pruneWorkspaceSpaceSelectedIds, resolveWorkspaceSpaceInspectedWorktreeId, @@ -105,6 +104,7 @@ import { type WorkspaceSpaceSortDirection, type WorkspaceSpaceSortKey } from './workspace-space-presentation' +import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' import { translate } from '@/i18n/i18n' const TREEMAP_FILLS = [ @@ -1490,12 +1490,15 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { return ( owner - ? getRuntimeGitStatus({ - settings: ownerSettings, - worktreeId: worktree.worktreeId, - worktreePath: worktree.path, - connectionId: owner.connectionId ?? undefined - }) + ? getRuntimeGitStatus( + { + settings: ownerSettings, + worktreeId: worktree.worktreeId, + worktreePath: worktree.path, + connectionId: owner.connectionId ?? undefined + }, + { admissionTier: 'background', includeLineStats: false } + ) : Promise.reject(new Error('Workspace owner is no longer available')) ) .then((status) => { @@ -1590,7 +1593,12 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }, [scanGeneration]) useEffect(() => { - const candidates = getWorkspaceSpaceGitStatusRefreshCandidates(sourceRows) + const visibleWorktreeIdentities = new Set(rows.map(getWorkspaceSpaceWorktreeIdentity)) + const candidates = getWorkspaceSpaceGitStatusRefreshCandidates(sourceRows, { + activeWorktreeId, + activeExecutionHostId: activeWorkspaceExecutionHostId, + visibleWorktreeIdentities + }) if (candidates.length === 0) { return } @@ -1613,7 +1621,13 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { return () => { cancelled = true } - }, [refreshWorkspaceGitStatus, sourceRows]) + }, [ + activeWorkspaceExecutionHostId, + activeWorktreeId, + refreshWorkspaceGitStatus, + rows, + sourceRows + ]) const inspectedWorktree = rows.find((row) => getWorkspaceSpaceWorktreeIdentity(row) === nextInspectedWorktreeId) ?? diff --git a/src/renderer/src/components/status-bar/workspace-space-git-status-order.ts b/src/renderer/src/components/status-bar/workspace-space-git-status-order.ts new file mode 100644 index 00000000000..9c7cf0eb771 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-git-status-order.ts @@ -0,0 +1,30 @@ +import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection' + +export function getWorkspaceSpaceGitStatusRefreshCandidates( + rows: readonly WorkspaceSpaceWorktree[], + options: { + activeWorktreeId?: string | null + activeExecutionHostId?: string | null + visibleWorktreeIdentities?: ReadonlySet + } = {} +): WorkspaceSpaceWorktree[] { + const candidates = rows.filter( + (worktree) => worktree.canDelete && worktree.status === 'ok' && !worktree.isMainWorktree + ) + const rank = (worktree: WorkspaceSpaceWorktree): number => { + const isActive = + worktree.worktreeId === options.activeWorktreeId && + (!options.activeExecutionHostId || worktree.executionHostId === options.activeExecutionHostId) + if (isActive) { + return 0 + } + return options.visibleWorktreeIdentities?.has(getWorkspaceSpaceWorktreeIdentity(worktree)) + ? 1 + : 2 + } + return candidates + .map((worktree, index) => ({ worktree, index, rank: rank(worktree) })) + .sort((left, right) => left.rank - right.rank || left.index - right.index) + .map(({ worktree }) => worktree) +} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index dea91ac7563..67e263b7d8f 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -11,7 +11,6 @@ import { filterWorkspaceSpaceRows, getLargestWorkspaceSpaceItemSize, getLargestWorkspaceSpaceRowSize, - getWorkspaceSpaceGitStatusRefreshCandidates, isWorkspaceSpaceFilterQueryTooLarge, isWorkspaceSpaceRowReadyToDelete, pruneWorkspaceSpaceSelectedIds, @@ -19,6 +18,7 @@ import { resolveWorkspaceSpaceTreemapZoomWorktreeId, sortWorkspaceSpaceRows } from './workspace-space-presentation' +import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' import { getWorkspaceDecisionDetails, getWorkspaceSpaceDeleteState, @@ -571,6 +571,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index eb76f5056a5..5edad9ec739 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -255,14 +255,6 @@ export function isWorkspaceSpaceRowReadyToDelete( ) } -export function getWorkspaceSpaceGitStatusRefreshCandidates( - rows: readonly WorkspaceSpaceWorktree[] -): WorkspaceSpaceWorktree[] { - return rows.filter( - (worktree) => worktree.canDelete && worktree.status === 'ok' && !worktree.isMainWorktree - ) -} - export function resolveWorkspaceSpaceInspectedWorktreeId( rows: readonly WorkspaceSpaceWorktree[], currentIdentity: string | null diff --git a/src/renderer/src/lib/window-visibility-interval.test.ts b/src/renderer/src/lib/window-visibility-interval.test.ts index cb870794cd8..5f097cd7eeb 100644 --- a/src/renderer/src/lib/window-visibility-interval.test.ts +++ b/src/renderer/src/lib/window-visibility-interval.test.ts @@ -127,4 +127,93 @@ describe('installWindowVisibilityInterval', () => { expect(setIntervalMock).toHaveBeenCalledTimes(1) cleanup() }) + + it('staggers visibilitychange runs across per-instance jitter delays', async () => { + vi.useFakeTimers() + let visibilityState: DocumentVisibilityState = 'hidden' + const listeners: (() => void)[] = [] + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((_event: string, listener: () => void) => listeners.push(listener)), + removeEventListener: vi.fn() + }) + const runs = [vi.fn(), vi.fn(), vi.fn()] + const cleanups = runs.map((run, index) => + installWindowVisibilityInterval({ + run, + intervalMs: 10_000, + jitterOnVisible: true, + jitterFn: () => [0, 100, 400][index] ?? 0 + }) + ) + + visibilityState = 'visible' + listeners.forEach((listener) => listener()) + expect(runs.map((run) => run.mock.calls.length)).toEqual([0, 0, 0]) + + await vi.advanceTimersByTimeAsync(0) + expect(runs.map((run) => run.mock.calls.length)).toEqual([1, 0, 0]) + await vi.advanceTimersByTimeAsync(99) + expect(runs.map((run) => run.mock.calls.length)).toEqual([1, 0, 0]) + await vi.advanceTimersByTimeAsync(1) + expect(runs.map((run) => run.mock.calls.length)).toEqual([1, 1, 0]) + await vi.advanceTimersByTimeAsync(300) + expect(runs.map((run) => run.mock.calls.length)).toEqual([1, 1, 1]) + + cleanups.forEach((cleanup) => cleanup()) + vi.useRealTimers() + }) + + it('does not jitter the install-time first run', () => { + vi.useFakeTimers() + const run = vi.fn() + vi.stubGlobal('document', { + visibilityState: 'visible', + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const cleanup = installWindowVisibilityInterval({ + run, + intervalMs: 3000, + jitterOnVisible: true, + jitterFn: () => 400 + }) + + expect(run).toHaveBeenCalledTimes(1) + cleanup() + vi.useRealTimers() + }) + + it('cancels a pending visibility jitter run on cleanup', async () => { + vi.useFakeTimers() + let visibilityState: DocumentVisibilityState = 'hidden' + let visibilityListener: (() => void) | undefined + const run = vi.fn() + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((_event: string, listener: () => void) => { + visibilityListener = listener + }), + removeEventListener: vi.fn() + }) + + const cleanup = installWindowVisibilityInterval({ + run, + intervalMs: 3000, + jitterOnVisible: true, + jitterFn: () => 400 + }) + visibilityState = 'visible' + visibilityListener?.() + cleanup() + + await vi.advanceTimersByTimeAsync(400) + expect(run).not.toHaveBeenCalled() + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/lib/window-visibility-interval.ts b/src/renderer/src/lib/window-visibility-interval.ts index aa1337fc095..35e4d294b60 100644 --- a/src/renderer/src/lib/window-visibility-interval.ts +++ b/src/renderer/src/lib/window-visibility-interval.ts @@ -1,4 +1,7 @@ export type WindowVisibilityIntervalTimer = ReturnType +export type WindowVisibilityJitterTimer = ReturnType + +const MAX_VISIBILITY_JITTER_MS = 400 export function isWindowVisible(): boolean { return ( @@ -17,6 +20,8 @@ export function installWindowVisibilityInterval(args: { intervalMs: number setIntervalFn?: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer clearIntervalFn?: (handle: WindowVisibilityIntervalTimer) => void + jitterOnVisible?: boolean + jitterFn?: () => number }): () => void { const setIntervalFn = args.setIntervalFn ?? @@ -25,19 +30,42 @@ export function installWindowVisibilityInterval(args: { const clearIntervalFn = args.clearIntervalFn ?? ((handle: WindowVisibilityIntervalTimer): void => clearInterval(handle)) let intervalId: WindowVisibilityIntervalTimer | null = null + let visibilityJitterId: WindowVisibilityJitterTimer | null = null + const visibilityJitterMs = args.jitterOnVisible + ? Math.max( + 0, + Math.min( + MAX_VISIBILITY_JITTER_MS, + Math.floor(args.jitterFn?.() ?? Math.random() * (MAX_VISIBILITY_JITTER_MS + 1)) + ) + ) + : 0 const stop = (): void => { - if (!intervalId) { - return + if (visibilityJitterId !== null) { + clearTimeout(visibilityJitterId) + visibilityJitterId = null + } + if (intervalId !== null) { + clearIntervalFn(intervalId) + intervalId = null } - clearIntervalFn(intervalId) - intervalId = null } - const start = (): void => { - if (intervalId || !isWindowVisible()) { + const start = (jitterVisibleRun: boolean): void => { + if (intervalId !== null || !isWindowVisible()) { return } - ;(args.runOnVisible ?? args.run)() + const visibleRun = args.runOnVisible ?? args.run + if (jitterVisibleRun) { + visibilityJitterId = setTimeout(() => { + visibilityJitterId = null + if (isWindowVisible()) { + visibleRun() + } + }, visibilityJitterMs) + } else { + visibleRun() + } // Why: many callers shell out or cross IPC. Keep their interval alive only // while Orca can present the refreshed data, but still refresh a visible // unfocused window so status UI does not go stale on a second display. @@ -45,13 +73,13 @@ export function installWindowVisibilityInterval(args: { } const reconcile = (): void => { if (isWindowVisible()) { - start() + start(args.jitterOnVisible === true) } else { stop() } } - start() + start(false) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', reconcile) } diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8754aee2b23..9b426035b60 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -9,6 +9,7 @@ import { fetchRuntimeGit, generateRuntimeCommitMessage, generateRuntimePullRequestFields, + getRuntimeGitBranchCompare, getRuntimeGitDiff, getRuntimeGitHistory, getRuntimeGitIgnoredPaths, @@ -29,6 +30,7 @@ const gitCancelStatus = vi.fn() const gitCheckIgnored = vi.fn() const gitSubmoduleStatus = vi.fn() const gitDiff = vi.fn() +const gitBranchCompare = vi.fn() const gitHistory = vi.fn() const gitBulkStage = vi.fn() const gitBulkDiscard = vi.fn() @@ -53,6 +55,7 @@ beforeEach(() => { gitCheckIgnored.mockReset() gitSubmoduleStatus.mockReset() gitDiff.mockReset() + gitBranchCompare.mockReset() gitHistory.mockReset() gitBulkStage.mockReset() gitBulkDiscard.mockReset() @@ -79,6 +82,7 @@ beforeEach(() => { checkIgnored: gitCheckIgnored, submoduleStatus: gitSubmoduleStatus, diff: gitDiff, + branchCompare: gitBranchCompare, history: gitHistory, bulkStage: gitBulkStage, bulkDiscard: gitBulkDiscard, @@ -99,6 +103,28 @@ beforeEach(() => { }) describe('runtime git client', () => { + it('preserves branch-compare admission through local IPC', async () => { + gitBranchCompare.mockResolvedValue({ summary: {}, entries: [] }) + + await getRuntimeGitBranchCompare( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + 'origin/main', + 'background' + ) + + expect(gitBranchCompare).toHaveBeenCalledWith({ + worktreePath: '/repo', + baseRef: 'origin/main', + connectionId: 'ssh-1', + admissionTier: 'background' + }) + }) + it('uses local git IPC when no remote runtime is active', async () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) @@ -182,6 +208,30 @@ describe('runtime git client', () => { }) }) + it('forwards a false line-stats request and accepts stats from an older local host', async () => { + const oldHostResult = { + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged', added: 3, removed: 2 }], + conflictOperation: 'unknown' + } + gitStatus.mockResolvedValue(oldHostResult) + + const result = await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { includeLineStats: false } + ) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + includeLineStats: false + }) + expect(result).toBe(oldHostResult) + }) + it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => { gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) @@ -427,6 +477,36 @@ describe('runtime git client', () => { }) }) + it('forwards a false line-stats request through the active runtime environment', async () => { + const oldHostResult = { + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged', added: 3, removed: 2 }], + conflictOperation: 'unknown' + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: oldHostResult, + _meta: { runtimeId: 'remote-runtime' } + }) + + const result = await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { includeLineStats: false } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', includeLineStats: false }, + timeoutMs: 15_000 + }) + expect(result).toEqual(oldHostResult) + }) + it('forwards upstream-negative-cache bypass through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-diff-client.ts b/src/renderer/src/runtime/runtime-git-diff-client.ts index ec112e78065..ea44571ecde 100644 --- a/src/renderer/src/runtime/runtime-git-diff-client.ts +++ b/src/renderer/src/runtime/runtime-git-diff-client.ts @@ -31,20 +31,22 @@ export async function getRuntimeGitDiff( export async function getRuntimeGitBranchCompare( context: RuntimeGitContext, - baseRef: string + baseRef: string, + admissionTier: 'interactive' | 'background' = 'interactive' ): Promise { const target = getActiveRuntimeTarget(context.settings) if (target.kind === 'local' || !context.worktreeId) { return window.api.git.branchCompare({ worktreePath: resolveLocalWorktreePath(context), baseRef, - connectionId: context.connectionId + connectionId: context.connectionId, + admissionTier }) } return callRuntimeRpc( target, 'git.branchCompare', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef }, + { worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef, admissionTier }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/runtime/runtime-git-status-client.ts b/src/renderer/src/runtime/runtime-git-status-client.ts index 78c8acb6034..048211bd137 100644 --- a/src/renderer/src/runtime/runtime-git-status-client.ts +++ b/src/renderer/src/runtime/runtime-git-status-client.ts @@ -11,7 +11,9 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' export async function getRuntimeGitStatus( context: RuntimeGitContext, options?: { + admissionTier?: 'interactive' | 'status' | 'background' includeIgnored?: boolean + includeLineStats?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean branchLineTotalMergeBase?: string @@ -20,6 +22,9 @@ export async function getRuntimeGitStatus( ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const admissionTierArgs = options?.admissionTier ? { admissionTier: options.admissionTier } : {} + const includeLineStatsArgs = + options?.includeLineStats === false ? { includeLineStats: false } : {} const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} @@ -32,7 +37,9 @@ export async function getRuntimeGitStatus( { worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, + ...admissionTierArgs, ...includeIgnoredArgs, + ...includeLineStatsArgs, ...upstreamCacheBypassArgs, ...lineStatsReuseArgs, ...branchLineTotalArgs @@ -45,7 +52,9 @@ export async function getRuntimeGitStatus( 'git.status', { worktree: toRuntimeWorktreeSelector(context.worktreeId), + ...admissionTierArgs, ...includeIgnoredArgs, + ...includeLineStatsArgs, ...upstreamCacheBypassArgs, ...lineStatsReuseArgs, ...branchLineTotalArgs diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 3233fc2f40b..72069802064 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -80,6 +80,7 @@ export function startPullRequestLookup(args: { branch, linkedPRNumber, currentHeadOid: requestHeadOid, + ...(options?.reason ? { reason: options.reason } : {}), ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -108,7 +109,10 @@ export function startPullRequestLookup(args: { cachedMergeStateStatus: cached?.data?.mergeStateStatus ?? null } const response = window.api.gh.refreshPRNow - ? await window.api.gh.refreshPRNow({ candidate }) + ? await window.api.gh.refreshPRNow({ + candidate, + reason: options?.reason ?? 'manual' + }) : await window.api.gh.prForBranch({ repoPath, repoId, @@ -248,7 +252,8 @@ export function startPullRequestLookup(args: { void get().fetchPRForBranch(repoPath, branch, { force: true, repoId, - worktreeId: options.worktreeId + worktreeId: options.worktreeId, + reason: options.reason }) } } diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts index bca0e2020d2..a6854f0ce3c 100644 --- a/src/renderer/src/store/github/refresh-routing-actions.ts +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -33,7 +33,8 @@ export const createRefreshRoutingActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason }) return } @@ -47,7 +48,8 @@ export const createRefreshRoutingActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason }) }) }, @@ -68,7 +70,8 @@ export const createRefreshRoutingActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'visible' }) continue } diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts index ab28629539d..c2c973c0e92 100644 --- a/src/renderer/src/store/github/refresh-sweep-actions.ts +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -133,7 +133,8 @@ export const createRefreshSweepActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'swr' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 }) @@ -207,7 +208,8 @@ export const createRefreshSweepActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'post-push' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 }) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index 0d1f41c5da6..e1dbd322e44 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -58,6 +58,7 @@ export type GitHubSlice = { linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: GitHubPRFallbackSource | null + reason?: GitHubPRRefreshReason } ) => Promise fetchIssue: ( diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts index 1ead3a5d360..61361907f6c 100644 --- a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -56,7 +56,8 @@ export const createStaleWorktreeRefreshActions = ( worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, - fallbackPRSource: candidate.fallbackPRSource ?? null + fallbackPRSource: candidate.fallbackPRSource ?? null, + reason: 'active' }) } else if (shouldEnqueueLocalPRRefresh(candidate)) { enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 }) diff --git a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts index 8bbde6cec19..41388aeafa9 100644 --- a/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-direct-refresh-scope.test.ts @@ -116,6 +116,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { ).resolves.toMatchObject({ number: 44 }) expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + reason: 'manual', candidate: expect.objectContaining({ repoId: 'repo-1', repoPath, diff --git a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts index ea32952a89e..d0c97a87222 100644 --- a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts @@ -409,6 +409,7 @@ describe('createGitHubSlice.fetchPRForBranch', () => { pr ) expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + reason: 'manual', candidate: expect.objectContaining({ repoId, repoPath, diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index f78393f62c7..33b3c87437c 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -126,41 +126,53 @@ describe('GitHub PR refresh owner-host routing', () => { resetRuntimeMocks() }) - it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { - runtimeEnvironmentCall.mockResolvedValueOnce({ - id: 'rpc-1', - ok: true, - result: makePR({ number: 23 }), - _meta: { runtimeId: 'remote-runtime' } - }) - const store = createTestStore() - const repoPath = '/runtime/repo' - const branch = 'feature/runtime-owner' - seed(store, { - settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], - repos: [ - makeRepo({ - id: 'repo-runtime', - path: repoPath, - executionHostId: 'runtime:env-1' - }) - ], - worktreesByRepo: { - 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] - } - }) + it.each([ + ['active', 80], + ['manual', 100] + ] as const)( + 'routes %s PR refresh to the runtime repo owner with its reason', + async (reason, priority) => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/runtime-owner' + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) - store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80) + store.getState().enqueueGitHubPRRefresh('wt-runtime', reason, priority) - await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) - expect(enqueuePRRefresh).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, - timeoutMs: 30_000 - }) - }) + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { + repo: 'repo-runtime', + branch, + linkedPRNumber: null, + currentHeadOid: 'head-oid', + reason + }, + timeoutMs: 30_000 + }) + } + ) it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => { const store = createTestStore() @@ -230,7 +242,13 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, + params: { + repo: 'repo-runtime', + branch, + linkedPRNumber: null, + currentHeadOid: 'head-oid', + reason: 'post-push' + }, timeoutMs: 30_000 }) }) @@ -278,7 +296,8 @@ describe('GitHub PR refresh owner-host routing', () => { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null, - currentHeadOid: 'head-oid' + currentHeadOid: 'head-oid', + reason: 'visible' }, timeoutMs: 30_000 }) diff --git a/src/renderer/src/store/slices/github-refresh-sweep.test.ts b/src/renderer/src/store/slices/github-refresh-sweep.test.ts index d275f9850a2..35c3e37f3cf 100644 --- a/src/renderer/src/store/slices/github-refresh-sweep.test.ts +++ b/src/renderer/src/store/slices/github-refresh-sweep.test.ts @@ -587,7 +587,13 @@ describe('createGitHubSlice.refreshAllGitHub', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: null, + currentHeadOid: null, + reason: 'swr' + }, timeoutMs: 30_000 }) }) @@ -717,7 +723,13 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: null, + currentHeadOid: null, + reason: 'post-push' + }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github-slice-test-harness.ts b/src/renderer/src/store/slices/github-slice-test-harness.ts index ef7aede802e..93fb6236a02 100644 --- a/src/renderer/src/store/slices/github-slice-test-harness.ts +++ b/src/renderer/src/store/slices/github-slice-test-harness.ts @@ -40,7 +40,8 @@ export const runtimeEnvironmentSubscribe: Mock< export const mockApi = { gh: { prForBranch: stubMock().mockResolvedValue(null), - refreshPRNow: stubMock<[{ candidate: GitHubPRRefreshCandidate }]>(), + refreshPRNow: + stubMock<[{ candidate: GitHubPRRefreshCandidate; reason?: GitHubPRRefreshReason }]>(), enqueuePRRefresh: stubMock< [{ candidate: GitHubPRRefreshCandidate; reason: GitHubPRRefreshReason; priority?: number }] diff --git a/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts b/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts index 430b83db0df..5603b063a5f 100644 --- a/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts +++ b/src/renderer/src/store/slices/github-worktree-refresh-if-stale.test.ts @@ -179,7 +179,10 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { await Promise.resolve() expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) - expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1) + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + candidate: expect.any(Object), + reason: 'active' + }) }) it('bounds rejected active PR refresh IPCs during worktree activation', async () => { @@ -530,7 +533,13 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: 12, currentHeadOid: null }, + params: { + repo: 'repo-1', + branch, + linkedPRNumber: 12, + currentHeadOid: null, + reason: 'active' + }, timeoutMs: 30_000 }) expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({ @@ -619,6 +628,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(mockApi.gh.refreshPRNow).toHaveBeenCalledWith({ + reason: 'manual', candidate: expect.objectContaining({ cacheKey: `ssh:ssh-1::repo-ssh::${branch}`, connectionId: 'ssh-1', diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts index 176a716561f..70b2b4d866b 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -19,6 +19,7 @@ export type HostedReviewCache = Record { expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview) }) + it('coalesces M card poll ticks into one trailing run after the slowTaskBackoff gap', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + let resolveRefresh: (value: HostedReviewInfo) => void = () => {} + const slowRefresh = new Promise((resolve) => { + resolveRefresh = resolve + }) + mockApi.hostedReview.forBranch + .mockResolvedValueOnce(review) + .mockReturnValueOnce(slowRefresh) + .mockResolvedValue(review) + const store = makeStore() + const options = { linkedGitHubPR: 42, staleWhileRevalidate: true } + + await store + .getState() + .fetchHostedReviewForBranch('/repo', 'feature/slow-poll', { linkedGitHubPR: 42 }) + vi.setSystemTime(60_001) + await store.getState().fetchHostedReviewForBranch('/repo', 'feature/slow-poll', options) + for (let tick = 0; tick < 5; tick += 1) { + await store.getState().fetchHostedReviewForBranch('/repo', 'feature/slow-poll', options) + } + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(10_000) + resolveRefresh(review) + await slowRefresh + await Promise.resolve() + await Promise.resolve() + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(49_999) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(3) + }) + + it('lets a force refresh supersede queued stale-while-revalidate work', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + let resolveBackground: (value: HostedReviewInfo) => void = () => {} + let resolveForce: (value: HostedReviewInfo) => void = () => {} + const background = new Promise((resolve) => { + resolveBackground = resolve + }) + const force = new Promise((resolve) => { + resolveForce = resolve + }) + const forcedReview = { ...review, title: 'Manual refresh result' } + mockApi.hostedReview.forBranch + .mockResolvedValueOnce(review) + .mockReturnValueOnce(background) + .mockReturnValueOnce(force) + .mockResolvedValue(review) + const store = makeStore() + const branch = 'feature/force-supersedes-poll' + const staleOptions = { linkedGitHubPR: 42, staleWhileRevalidate: true } + + await store.getState().fetchHostedReviewForBranch('/repo', branch, { linkedGitHubPR: 42 }) + vi.setSystemTime(60_001) + await store.getState().fetchHostedReviewForBranch('/repo', branch, staleOptions) + await store.getState().fetchHostedReviewForBranch('/repo', branch, staleOptions) + const forceRefresh = store + .getState() + .fetchHostedReviewForBranch('/repo', branch, { linkedGitHubPR: 42, force: true }) + await store.getState().fetchHostedReviewForBranch('/repo', branch, staleOptions) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(3) + + await vi.advanceTimersByTimeAsync(10_000) + resolveBackground(review) + await background + await Promise.resolve() + await vi.advanceTimersByTimeAsync(300_000) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(3) + + resolveForce(forcedReview) + await expect(forceRefresh).resolves.toEqual(forcedReview) + await vi.advanceTimersByTimeAsync(300_000) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(3) + }) + it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => { vi.useFakeTimers() vi.setSystemTime(0) diff --git a/src/renderer/src/store/slices/hosted-review-card-refresh.ts b/src/renderer/src/store/slices/hosted-review-card-refresh.ts index d211d3be685..a6f9be139bb 100644 --- a/src/renderer/src/store/slices/hosted-review-card-refresh.ts +++ b/src/renderer/src/store/slices/hosted-review-card-refresh.ts @@ -12,6 +12,7 @@ type RefreshHostedReviewCardArgs = { repoPath: string repoId: string branch: string + admissionTier?: 'interactive' | 'status' | 'background' linkedGitHubPR?: number | null fallbackGitHubPR?: number | null linkedGitLabMR?: number | null @@ -29,6 +30,7 @@ export function refreshHostedReviewCard( return fetchHostedReviewForBranch(args.repoPath, args.branch, { force: true, repoId: args.repoId, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}), repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, linkedGitHubPR: args.linkedGitHubPR ?? null, ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), diff --git a/src/renderer/src/store/slices/hosted-review-request-state.ts b/src/renderer/src/store/slices/hosted-review-request-state.ts index 5660bb6918c..15621e378bf 100644 --- a/src/renderer/src/store/slices/hosted-review-request-state.ts +++ b/src/renderer/src/store/slices/hosted-review-request-state.ts @@ -1,4 +1,8 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import { slowTaskRequiredIdleMs } from '@/components/right-sidebar/coalesced-poll-runner' + +const HOSTED_REVIEW_REVALIDATION_IDLE_MULTIPLIER = 5 +const HOSTED_REVIEW_REVALIDATION_MAX_INTERVAL_MS = 5 * 60_000 export const inflightHostedReviewRequests = new Map< string, @@ -6,11 +10,151 @@ export const inflightHostedReviewRequests = new Map< promise: Promise force: boolean generation: number - linkedReviewHintKey: string + startedAt: number } >() export const hostedReviewRequestGenerations = new Map() +type HostedReviewRevalidationLane = { + inFlight: Promise | null + lastRunDurationMs: number + lastRunEndedAt: number + pendingStartRequest: (() => Promise) | null + timer: ReturnType | null +} +const hostedReviewRevalidationLanes = new Map() + +export function hostedReviewRequestKey(cacheKey: string, hintKey: string): string { + return `${cacheKey}\0${hintKey}` +} + +function requiredHostedReviewRevalidationIdleMs(lane: HostedReviewRevalidationLane): number { + return slowTaskRequiredIdleMs( + lane.lastRunDurationMs, + HOSTED_REVIEW_REVALIDATION_IDLE_MULTIPLIER, + 0, + HOSTED_REVIEW_REVALIDATION_MAX_INTERVAL_MS + ) +} + +function clearHostedReviewRevalidationTimer(lane: HostedReviewRevalidationLane): void { + if (lane.timer !== null) { + clearTimeout(lane.timer) + lane.timer = null + } +} + +function scheduleHostedReviewRevalidationLane( + requestKey: string, + lane: HostedReviewRevalidationLane +): void { + if (lane.inFlight || lane.timer !== null) { + return + } + const allowedAt = lane.lastRunEndedAt + requiredHostedReviewRevalidationIdleMs(lane) + const delayMs = allowedAt - Date.now() + if (delayMs <= 0 && lane.pendingStartRequest) { + startHostedReviewRevalidationLane(requestKey, lane) + return + } + lane.timer = setTimeout( + () => { + lane.timer = null + if (lane.pendingStartRequest) { + startHostedReviewRevalidationLane(requestKey, lane) + } else if (!lane.inFlight) { + hostedReviewRevalidationLanes.delete(requestKey) + } + }, + Math.max(0, delayMs) + ) +} + +function observeHostedReviewRevalidationPromise( + requestKey: string, + lane: HostedReviewRevalidationLane, + promise: Promise, + startedAt: number +): void { + lane.inFlight = promise + const finish = (): void => { + if (lane.inFlight !== promise) { + return + } + lane.inFlight = null + lane.lastRunEndedAt = Date.now() + lane.lastRunDurationMs = lane.lastRunEndedAt - startedAt + scheduleHostedReviewRevalidationLane(requestKey, lane) + } + void promise.then(finish, finish) +} + +function startHostedReviewRevalidationLane( + requestKey: string, + lane: HostedReviewRevalidationLane +): void { + const startRequest = lane.pendingStartRequest + if (!startRequest) { + return + } + clearHostedReviewRevalidationTimer(lane) + lane.pendingStartRequest = null + const startedAt = Date.now() + const promise = startRequest() + if (lane.inFlight !== promise) { + observeHostedReviewRevalidationPromise(requestKey, lane, promise, startedAt) + } +} + +export function supersedeHostedReviewRevalidation( + requestKey: string, + request: { promise: Promise; startedAt: number } +): void { + const lane = hostedReviewRevalidationLanes.get(requestKey) + if (!lane) { + return + } + clearHostedReviewRevalidationTimer(lane) + lane.pendingStartRequest = null + observeHostedReviewRevalidationPromise(requestKey, lane, request.promise, request.startedAt) +} + +export function queueHostedReviewRevalidation( + requestKey: string, + startRequest: () => Promise, + inflightRequest?: { + promise: Promise + startedAt: number + force: boolean + } +): void { + if (inflightRequest?.force) { + supersedeHostedReviewRevalidation(requestKey, inflightRequest) + return + } + let lane = hostedReviewRevalidationLanes.get(requestKey) + if (!lane) { + lane = { + inFlight: null, + lastRunDurationMs: 0, + lastRunEndedAt: -Infinity, + pendingStartRequest: null, + timer: null + } + hostedReviewRevalidationLanes.set(requestKey, lane) + } + lane.pendingStartRequest = startRequest + if (!lane.inFlight && inflightRequest) { + observeHostedReviewRevalidationPromise( + requestKey, + lane, + inflightRequest.promise, + inflightRequest.startedAt + ) + return + } + scheduleHostedReviewRevalidationLane(requestKey, lane) +} /** @internal - exposed for leak-regression tests only */ export function _getHostedReviewRequestGenerationCountForTest(): number { @@ -20,4 +164,26 @@ export function _getHostedReviewRequestGenerationCountForTest(): number { /** @internal - exposed for leak-regression tests only */ export function _clearHostedReviewRequestGenerationsForTest(): void { hostedReviewRequestGenerations.clear() + inflightHostedReviewRequests.clear() + for (const lane of hostedReviewRevalidationLanes.values()) { + clearHostedReviewRevalidationTimer(lane) + } + hostedReviewRevalidationLanes.clear() +} + +/** Records a freshly issued request in the in-flight map and supersedes any queued revalidation for its key. */ +export function registerInflightHostedReviewRequest( + requestKey: string, + entry: { + promise: Promise + force: boolean + generation: number + startedAt: number + } +): void { + inflightHostedReviewRequests.set(requestKey, entry) + supersedeHostedReviewRevalidation(requestKey, { + promise: entry.promise, + startedAt: entry.startedAt + }) } diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 17d8cb20e2e..6ada2a4b0d1 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -605,6 +605,23 @@ describe('hosted review slice', () => { }) }) + it('marks an explicit card refresh interactive', async () => { + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + + await refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: '/repo', + repoId: 'repo-id', + branch: 'feature/test', + admissionTier: 'interactive' + }) + + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith( + '/repo', + 'feature/test', + expect.objectContaining({ admissionTier: 'interactive' }) + ) + }) + it('refetches a fresh null branch result when a linked PR hint is later available', async () => { mockApi.hostedReview.forBranch.mockResolvedValueOnce(null).mockResolvedValueOnce(review) const store = makeStore() diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index f538d78d007..53cdb00db55 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -14,7 +14,6 @@ import { type LinkedReviewHints } from './hosted-review-cache-identity' import { - canReuseInflightHint, findHostedReviewRepoByPath, findHostedReviewRepoForFetch, hasNewerHostedReviewCacheEntry, @@ -34,8 +33,11 @@ import { } from './hosted-review-cache-state' import { clearHostedReviewConflictingPrCache } from './hosted-review-pr-cache' import { + hostedReviewRequestKey, hostedReviewRequestGenerations as requestGenerations, - inflightHostedReviewRequests + inflightHostedReviewRequests, + queueHostedReviewRevalidation, + registerInflightHostedReviewRequest } from './hosted-review-request-state' export type HostedReviewSlice = { @@ -168,6 +170,7 @@ export const createHostedReviewSlice: StateCreator => { const generation = (requestGenerations.get(cacheKey) ?? 0) + 1 const requestStartedAt = Date.now() @@ -196,6 +196,7 @@ export const createHostedReviewSlice: StateCreator { worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, - fallbackPRSource: 'explicit' + fallbackPRSource: 'explicit', + reason: 'active' }) for (let i = 0; i < 6; i++) { await Promise.resolve() @@ -231,7 +232,8 @@ describe('worktree remote runtime mutations', () => { worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, - fallbackPRSource: 'explicit' + fallbackPRSource: 'explicit', + reason: 'active' }) for (let i = 0; i < 6; i++) { await Promise.resolve() @@ -422,7 +424,8 @@ describe('worktree remote runtime mutations', () => { worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, - fallbackPRSource: 'explicit' + fallbackPRSource: 'explicit', + reason: 'active' }) for (let i = 0; i < 6; i++) { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index 0f20689cb44..72d95b6a0a4 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -129,7 +129,8 @@ export function createObserveTerminalGitHubPullRequestLink( worktreeId, linkedPRNumber: alreadyLinked ? link.number : null, fallbackPRNumber: null, - fallbackPRSource: alreadyLinked ? null : 'explicit' + fallbackPRSource: alreadyLinked ? null : 'explicit', + reason: 'active' }).then((pr) => { if (!alreadyLinked && pr?.number === link.number) { // Why: terminal output can carry arbitrary PR URLs (docs/agents/logs). diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts index f0b0423270a..9b6142f9089 100644 --- a/src/renderer/src/web/preload-api/web-git-api.ts +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -44,6 +44,7 @@ export function createGitApi(): NonNullable['git']> { status: async ({ worktreePath, includeIgnored, + includeLineStats, bypassEffectiveUpstreamNegativeCache, reuseLineStats, branchLineTotalMergeBase, @@ -53,6 +54,7 @@ export function createGitApi(): NonNullable['git']> { const params = { worktree: toRuntimeWorktreeSelector(worktree.id), includeIgnored, + includeLineStats, bypassEffectiveUpstreamNegativeCache, reuseLineStats, ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) @@ -120,11 +122,12 @@ export function createGitApi(): NonNullable['git']> { compareAgainstHead }) }, - branchCompare: async ({ worktreePath, baseRef }) => { + branchCompare: async ({ worktreePath, baseRef, admissionTier }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.branchCompare', { worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef + baseRef, + ...(admissionTier ? { admissionTier } : {}) }) }, commitCompare: async ({ worktreePath, commitId }) => { diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts index dfc955a0768..330c2a8e11e 100644 --- a/src/renderer/src/web/preload-api/web-github-api.ts +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -24,7 +24,7 @@ export function createGitHubApi(): WebGitHubApi { route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), prForBranch: (args) => route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), - refreshPRNow: async ({ candidate }) => { + refreshPRNow: async ({ candidate, reason }) => { const acceptMergedFallbackPR = candidate.linkedPRNumber == null && candidate.fallbackPRNumber != null && @@ -36,6 +36,7 @@ export function createGitHubApi(): WebGitHubApi { linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, currentHeadOid: candidate.currentHeadOid ?? null, + ...(reason ? { reason } : {}), ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) }) return pr diff --git a/src/renderer/src/web/web-preload-api-git.test.ts b/src/renderer/src/web/web-preload-api-git.test.ts index e96eceb573b..3ee22a46aa3 100644 --- a/src/renderer/src/web/web-preload-api-git.test.ts +++ b/src/renderer/src/web/web-preload-api-git.test.ts @@ -164,6 +164,15 @@ describe('web git preload API', () => { branchLineTotalMergeBase: TEST_COMMIT_OID }) await globals.window.api.git.status({ worktreePath: '/workspace/repo' }) + await globals.window.api.git.status({ + worktreePath: '/workspace/repo', + includeLineStats: false + }) + await globals.window.api.git.branchCompare({ + worktreePath: '/workspace/repo', + baseRef: 'origin/main', + admissionTier: 'background' + }) const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status') // Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`, @@ -174,6 +183,7 @@ describe('web git preload API', () => { params: { worktree: 'id:wt-1', includeIgnored: undefined, + includeLineStats: undefined, bypassEffectiveUpstreamNegativeCache: undefined, reuseLineStats: undefined, branchLineTotalMergeBase: TEST_COMMIT_OID @@ -184,10 +194,29 @@ describe('web git preload API', () => { params: { worktree: 'id:wt-1', includeIgnored: undefined, + includeLineStats: undefined, + bypassEffectiveUpstreamNegativeCache: undefined, + reuseLineStats: undefined + } + }, + { + method: 'git.status', + params: { + worktree: 'id:wt-1', + includeIgnored: undefined, + includeLineStats: false, bypassEffectiveUpstreamNegativeCache: undefined, reuseLineStats: undefined } } ]) + expect(runtimeCalls.find((call) => call.method === 'git.branchCompare')).toStrictEqual({ + method: 'git.branchCompare', + params: { + worktree: 'id:wt-1', + baseRef: 'origin/main', + admissionTier: 'background' + } + }) }) }) diff --git a/src/shared/child-process/child-termination-reporter.ts b/src/shared/child-process/child-termination-reporter.ts new file mode 100644 index 00000000000..907c98b9405 --- /dev/null +++ b/src/shared/child-process/child-termination-reporter.ts @@ -0,0 +1,16 @@ +export type ChildTerminationReporter = { + report: () => void + reportIf: (confirmed: boolean) => void +} + +export function createChildTerminationReporter(callback?: () => void): ChildTerminationReporter { + let reported = false + const report = (): void => { + if (reported) { + return + } + reported = true + callback?.() + } + return { report, reportIf: (confirmed) => (confirmed ? report() : undefined) } +} diff --git a/src/shared/child-process/process-spec.ts b/src/shared/child-process/process-spec.ts new file mode 100644 index 00000000000..ac705974efe --- /dev/null +++ b/src/shared/child-process/process-spec.ts @@ -0,0 +1,71 @@ +// The public contract for Orca's single child-process entry point. Split from +// run-process.ts so the runner stays under its line cap; import the runtime +// functions from run-process, which re-exports everything here. +import type { ChildProcess, SpawnOptions as NodeSpawnOptions } from 'node:child_process' + +export type ChildProcessHandle = ChildProcess + +export type SpawnedProcess = ChildProcess + +/** + * The single place Orca starts a child process. + * + * Why one place: six decisions have to be made every time a child is spawned, + * POSIX forgives all six, and Windows punishes each of them differently — + * console visibility, argument quoting, `.cmd` interpretation, binary + * resolution, timeout policy, and how the tree is later terminated. Made + * per-call-site, they were right in some files and wrong in others, and the + * wrong ones reached users as stolen keyboard focus, mangled agent prompts and + * orphaned process trees. + * + * Callers outside this directory must not import `node:child_process`; a guard + * test enforces that against a shrinking allowlist. + */ + +export type ProcessSpec = { + /** + * Program to run. On Windows this should already be an absolute path — + * spawning by bare name depends on the child's PATH, which under Group Policy + * or a stripped Electron environment can resolve to nothing. + */ + program: string + args?: readonly string[] + cwd?: string + env?: NodeJS.ProcessEnv + /** Kill the process (and, on Windows, its console) after this long. */ + timeoutMs?: number | null + /** Written to stdin then closed. Omit to leave stdin empty and closed. */ + input?: string + /** Cap on captured stdout/stderr; output past it is discarded. */ + maxOutputBytes?: number + /** Kills the process when aborted; the result still reports the exit. */ + signal?: AbortSignal + /** Keep the child in its own POSIX process group for tree termination. */ + detached?: boolean + /** Preserve a caller-owned Windows command line such as a cmd.exe invocation. */ + windowsVerbatimArguments?: boolean + /** Streaming callers may suppress child output for auxiliary processes. */ + stdio?: NodeSpawnOptions['stdio'] + /** Kill the whole process tree and do not settle until termination is verified. */ + terminationBarrier?: boolean | ProcessTerminationBarrier + /** Called once when the child exits or tree termination is verified. */ + onChildTerminated?: () => void +} + +export type ProcessTerminationBarrier = { + observeStderr?: (chunk: Buffer | string) => void + signal: (child: ChildProcess, signal?: NodeJS.Signals) => Promise + force: (child: ChildProcess) => Promise +} + +export type ProcessResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string + /** True when the process was killed by `timeoutMs` rather than exiting. */ + timedOut: boolean +} + +export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 +export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 diff --git a/src/shared/child-process/run-process-termination-failure.test.ts b/src/shared/child-process/run-process-termination-failure.test.ts index d608540ee30..3ba2e6b2610 100644 --- a/src/shared/child-process/run-process-termination-failure.test.ts +++ b/src/shared/child-process/run-process-termination-failure.test.ts @@ -38,6 +38,34 @@ describe('runProcess termination failure', () => { vi.clearAllMocks() }) + it('reports ordinary child close exactly once', async () => { + const child = mockChild() + const onChildTerminated = vi.fn() + spawnMock.mockReturnValue(child) + const pending = runProcess({ program: 'git', timeoutMs: null, onChildTerminated }) + + child.emit('close', 0, null) + child.emit('close', 0, null) + + await expect(pending).resolves.toMatchObject({ code: 0 }) + expect(onChildTerminated).toHaveBeenCalledOnce() + }) + + it('does not report a live-child error before its eventual close', async () => { + const child = mockChild() + const onChildTerminated = vi.fn() + spawnMock.mockReturnValue(child) + const pending = runProcess({ program: 'git', timeoutMs: null, onChildTerminated }) + const rejection = expect(pending).rejects.toThrow('delivery failed') + + child.emit('error', new Error('delivery failed')) + expect(onChildTerminated).not.toHaveBeenCalled() + child.emit('close', null, 'SIGKILL') + + await rejection + expect(onChildTerminated).toHaveBeenCalledOnce() + }) + it('holds the result until the barrier deadline when tree termination cannot be verified', async () => { const child = mockChild() spawnMock.mockReturnValue(child) @@ -63,17 +91,31 @@ describe('runProcess termination failure', () => { async () => { forceTerminateProcessTreeMock.mockResolvedValue(true) spawnMock.mockReturnValue(mockChild()) - const pending = runProcess({ program: 'git', timeoutMs: 10, terminationBarrier: true }) + const onChildTerminated = vi.fn() + const pending = runProcess({ + program: 'git', + timeoutMs: 10, + terminationBarrier: true, + onChildTerminated + }) await vi.advanceTimersByTimeAsync(2_010) await expect(pending).resolves.toMatchObject({ timedOut: true }) + expect(onChildTerminated).toHaveBeenCalledOnce() } ) it('settles on the barrier deadline when the root never reports', async () => { - spawnMock.mockReturnValue(mockChild()) - const pending = runProcess({ program: 'git', timeoutMs: 10, terminationBarrier: true }) + const onChildTerminated = vi.fn() + const child = mockChild() + spawnMock.mockReturnValue(child) + const pending = runProcess({ + program: 'git', + timeoutMs: 10, + terminationBarrier: true, + onChildTerminated + }) let settled = false void pending.then(() => { settled = true @@ -81,9 +123,13 @@ describe('runProcess termination failure', () => { await vi.advanceTimersByTimeAsync(2_010) expect(settled).toBe(false) + expect(onChildTerminated).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(10_000) await expect(pending).resolves.toMatchObject({ code: null, timedOut: true }) + expect(onChildTerminated).not.toHaveBeenCalled() + child.emit('close', null, 'SIGKILL') + expect(onChildTerminated).toHaveBeenCalledOnce() }) it('retains a root exit observed before barrier shutdown', async () => { diff --git a/src/shared/child-process/run-process.test.ts b/src/shared/child-process/run-process.test.ts index 55ee378c69e..5fad9b5dc70 100644 --- a/src/shared/child-process/run-process.test.ts +++ b/src/shared/child-process/run-process.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' @@ -189,12 +189,15 @@ describe('a signal that is already aborted', () => { const controller = new AbortController() controller.abort() const startedAt = Date.now() + const onChildTerminated = vi.fn() const result = await runProcess({ program: path.join(tmpdir(), 'orca-must-not-spawn'), timeoutMs: 30_000, - signal: controller.signal + signal: controller.signal, + onChildTerminated }) expect(result.timedOut).toBe(false) + expect(onChildTerminated).toHaveBeenCalledOnce() expect(Date.now() - startedAt).toBeLessThan(10_000) }, 20_000) }) diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index 781afb30eee..ec83c5beed0 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -9,71 +9,18 @@ import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windo import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination' import { createOutputSink } from './bounded-output-sink' +import { createChildTerminationReporter } from './child-termination-reporter' -export type ChildProcessHandle = ChildProcess - -export type SpawnedProcess = ChildProcess - -/** - * The single place Orca starts a child process. - * - * Why one place: six decisions have to be made every time a child is spawned, - * POSIX forgives all six, and Windows punishes each of them differently — - * console visibility, argument quoting, `.cmd` interpretation, binary - * resolution, timeout policy, and how the tree is later terminated. Made - * per-call-site, they were right in some files and wrong in others, and the - * wrong ones reached users as stolen keyboard focus, mangled agent prompts and - * orphaned process trees. - * - * Callers outside this directory must not import `node:child_process`; a guard - * test enforces that against a shrinking allowlist. - */ - -export type ProcessSpec = { - /** - * Program to run. On Windows this should already be an absolute path — - * spawning by bare name depends on the child's PATH, which under Group Policy - * or a stripped Electron environment can resolve to nothing. - */ - program: string - args?: readonly string[] - cwd?: string - env?: NodeJS.ProcessEnv - /** Kill the process (and, on Windows, its console) after this long. */ - timeoutMs?: number | null - /** Written to stdin then closed. Omit to leave stdin empty and closed. */ - input?: string - /** Cap on captured stdout/stderr; output past it is discarded. */ - maxOutputBytes?: number - /** Kills the process when aborted; the result still reports the exit. */ - signal?: AbortSignal - /** Keep the child in its own POSIX process group for tree termination. */ - detached?: boolean - /** Preserve a caller-owned Windows command line such as a cmd.exe invocation. */ - windowsVerbatimArguments?: boolean - /** Streaming callers may suppress child output for auxiliary processes. */ - stdio?: NodeSpawnOptions['stdio'] - /** Kill the whole process tree and do not settle until termination is verified. */ - terminationBarrier?: boolean | ProcessTerminationBarrier -} - -export type ProcessTerminationBarrier = { - observeStderr?: (chunk: Buffer | string) => void - signal: (child: ChildProcess, signal?: NodeJS.Signals) => Promise - force: (child: ChildProcess) => Promise -} - -export type ProcessResult = { - code: number | null - signal: NodeJS.Signals | null - stdout: string - stderr: string - /** True when the process was killed by `timeoutMs` rather than exiting. */ - timedOut: boolean -} - -export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 -export const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +export type { + ChildProcessHandle, + SpawnedProcess, + ProcessSpec, + ProcessTerminationBarrier, + ProcessResult +} from './process-spec' +export { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec' +import type { ProcessSpec, ProcessResult } from './process-spec' +import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec' /** * Grace between the timeout kill and giving up on the child's exit. * @@ -166,15 +113,18 @@ export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams */ export function runProcess(spec: ProcessSpec): Promise { if (spec.signal?.aborted) { + spec.onChildTerminated?.() return Promise.resolve({ code: null, signal: null, stdout: '', stderr: '', timedOut: false }) } const maxOutputBytes = spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES return new Promise((resolve, reject) => { + const terminationReporter = createChildTerminationReporter(spec.onChildTerminated) let child: ChildProcess try { child = spawnProcess(spec) } catch (error) { + terminationReporter.report() reject(error) return } @@ -282,6 +232,7 @@ export function runProcess(spec: ProcessSpec): Promise { } barrierAttemptComplete = true barrierTerminationVerified = true + terminationReporter.report() resolveBarrierIfSafe() }) } @@ -297,6 +248,7 @@ export function runProcess(spec: ProcessSpec): Promise { ([initialTerminated, forceTerminated]) => { barrierAttemptComplete = true barrierTerminationVerified = initialTerminated || forceTerminated + terminationReporter.reportIf(barrierTerminationVerified) if (!barrierTerminationVerified) { // The barrier never confirmed the tree died, so the root // would otherwise outlive the abort or timeout. @@ -313,6 +265,7 @@ export function runProcess(spec: ProcessSpec): Promise { } barrierAttemptComplete = true barrierTerminationVerified = terminated + terminationReporter.reportIf(barrierTerminationVerified) resolveBarrierIfSafe() }) return @@ -321,6 +274,7 @@ export function runProcess(spec: ProcessSpec): Promise { ([_initialTerminated, forceTerminated]) => { barrierAttemptComplete = true barrierTerminationVerified = forceTerminated + terminationReporter.reportIf(barrierTerminationVerified) if (!barrierTerminationVerified) { terminate(child, 'SIGKILL') } @@ -356,6 +310,7 @@ export function runProcess(spec: ProcessSpec): Promise { } child.once('error', (error) => { + terminationReporter.reportIf(!child.pid) if (barrierStopping) { deferredError = error resolveBarrierIfSafe() @@ -373,6 +328,7 @@ export function runProcess(spec: ProcessSpec): Promise { } }) child.once('close', (code, signal) => { + terminationReporter.report() if (!barrierStopping) { rootExitedBeforeBarrier = true } diff --git a/src/shared/github/pull-request-refresh-types.ts b/src/shared/github/pull-request-refresh-types.ts index 261773b327f..2456b59be60 100644 --- a/src/shared/github/pull-request-refresh-types.ts +++ b/src/shared/github/pull-request-refresh-types.ts @@ -74,7 +74,9 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedChecksStatus?: CheckStatus | null cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null - localGitOptions?: { wslDistro?: string } + localGitOptions?: { + wslDistro?: string + } } export type GitHubPRRefreshSkippedReason = diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 7837c2cc370..719c6d27fa5 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -55,6 +55,7 @@ export type HostedReviewInfo = { export type HostedReviewForBranchArgs = { repoPath: string repoId?: string + admissionTier?: 'interactive' | 'status' | 'background' /** Desktop IPC-only owner guard; runtime RPC callers omit this field. */ repoOwnerExecutionHostId?: string branch: string