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