Merge remote-tracking branch 'origin/main' into entrypoint-ssh-openclaw-final

This commit is contained in:
Merge Sim
2026-09-01 23:08:39 -07:00
521 changed files with 27194 additions and 4163 deletions
+5
View File
@@ -117,6 +117,11 @@ export class AgentAwakeService {
}
}
/** Agents this runtime has seen working recently, independent of the awake setting. */
getWorkingAgentCount(): number {
return this.getEligibleRunningStatusCount()
}
subscribe(listener: (status: ComputerAwakeStatus) => void): () => void {
this.statusListeners.add(listener)
return () => this.statusListeners.delete(listener)
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import type { EnrichedAgentHookEventPayload } from './server/server-types'
import { buildBody, PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
const CONNECTION = 'conn-1'
const T0 = 1_800_000_000_000
function ingest(
server: AgentHookServer,
payload: Record<string, unknown>,
options: { isReplay?: boolean } = {}
): void {
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildBody(payload),
'production'
)
if (!event) {
throw new Error('normalizeHookPayload rejected a known-good Claude fixture')
}
server.ingestRemote({ ...event, ...(options.isReplay ? { isReplay: true } : {}) }, CONNECTION)
}
describe('the observation clock a relay replay must not restamp', () => {
let server: AgentHookServer
let emitted: EnrichedAgentHookEventPayload[]
beforeEach(() => {
_internals.resetCachesForTests()
vi.useFakeTimers()
vi.setSystemTime(T0)
server = new AgentHookServer()
emitted = []
server.setListener((payload) => {
emitted.push(payload)
})
})
afterEach(() => {
server.setListener(null)
vi.useRealTimers()
vi.restoreAllMocks()
})
const lastForPane = (): EnrichedAgentHookEventPayload =>
emitted.toReversed().find((event) => event.paneKey === PANE)!
it('holds the observation time across a reconnect replay while delivery order advances', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
expect(lastForPane().evidenceObservedAt).toBe(T0)
vi.setSystemTime(T0 + 25 * 60 * 1000)
// A lost transport clears the row; the age of the evidence it restates is not a claim.
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
const replayed = lastForPane()
expect(replayed.payload.state).toBe('working')
// Delivery order must still clear the connection watermark, or the renderer drops the row.
expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1)
expect(replayed.evidenceObservedAt).toBe(T0)
})
it('lets a live event restamp the observation time after a replay', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
vi.setSystemTime(T0 + 25 * 60 * 1000)
server.clearStatusEntriesForConnection(CONNECTION)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
{ isReplay: true }
)
vi.setSystemTime(T0 + 26 * 60 * 1000)
ingest(server, { hook_event_name: 'PreToolUse', tool_name: 'Edit' })
expect(lastForPane().evidenceObservedAt).toBe(T0 + 26 * 60 * 1000)
})
it('gives a torn-down pane no inherited observation time', () => {
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
server.clearPaneState(PANE)
vi.setSystemTime(T0 + 25 * 60 * 1000)
ingest(
server,
{ hook_event_name: 'UserPromptSubmit', prompt: 'a new session' },
{ isReplay: true }
)
expect(lastForPane().evidenceObservedAt).toBe(T0 + 25 * 60 * 1000)
})
})
@@ -97,6 +97,10 @@ export abstract class AgentHookServerState {
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// Why: survives the row itself. A transport clear deletes the pane's status row on purpose
// (absence, not completion), but the *age* of the evidence a later replay restates is not a
// claim about the pane and must not be lost with it. Bounded like its sibling maps.
protected evidenceObservedAtByPaneKey = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
protected lastWrittenJson: string | null = null
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
@@ -13,6 +13,9 @@ import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
import { AgentHookServerStatusDisposition } from './server-status-disposition'
/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */
const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
protected attachStatusTiming(
payload: AgentHookEventPayload,
@@ -41,10 +44,38 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
return {
...payload,
receivedAt: now,
evidenceObservedAt: this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt
}
}
/**
* A replay restates evidence already observed; it is not a new observation. Keeping
* `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the
* renderer's four `<` drops all depend on it), while this clock records when the evidence
* was actually seen — so the staleness window measures age, not reconnect count.
* Without a remembered time the honest answer is `now`, which is today's behaviour.
*/
private resolveEvidenceObservedAt(
payload: AgentHookEventPayload,
previous: EnrichedAgentHookEventPayload | undefined,
now: number
): number {
const remembered =
previous?.evidenceObservedAt ?? this.evidenceObservedAtByPaneKey.get(payload.paneKey)
const observedAt = payload.isReplay === true && remembered !== undefined ? remembered : now
this.evidenceObservedAtByPaneKey.delete(payload.paneKey)
this.evidenceObservedAtByPaneKey.set(payload.paneKey, observedAt)
while (this.evidenceObservedAtByPaneKey.size > MAX_REMEMBERED_EVIDENCE_OBSERVATIONS) {
const oldest = this.evidenceObservedAtByPaneKey.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.evidenceObservedAtByPaneKey.delete(oldest)
}
return observedAt
}
protected hashPromptForTelemetryDedupe(prompt: string): string {
return createHash('sha256')
.update(this.promptSentHashSalt)
@@ -94,6 +94,8 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
// Why: the pane itself is gone, so its observation clock describes nothing a later pane owns.
this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) {
if (alias.stablePaneKey === resolvedPaneKey) {
@@ -105,6 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
this.evidenceObservedAtByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
@@ -11,6 +11,11 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-ty
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
/** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect
* replays cached rows and `receivedAt` must restamp to clear the connection watermark, so
* only this clock can answer how old the evidence itself is. Persisted so it survives a
* main restart; absent means "never separately observed" and consumers use `receivedAt`. */
evidenceObservedAt?: number
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
+6 -2
View File
@@ -35,7 +35,9 @@ export class CliCommandInstallation extends CliCommandInspection {
const inspected = await this.inspectStableSymlink(commandPath, launcherPath)
if (inspected.status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`)
throw new Error(
`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`
)
}
if (inspected.status.state === 'installed') {
return
@@ -54,7 +56,9 @@ export class CliCommandInstallation extends CliCommandInspection {
if (!(await capturedExpectedEntry(quarantine, inspected))) {
await this.restoreQuarantinedCommand(quarantine, commandPath)
throw new Error(`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`)
throw new Error(
`Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.`
)
}
try {
+6 -2
View File
@@ -116,7 +116,9 @@ export class CliInstaller extends CliPathRegistration {
throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.')
}
if (initialStatus.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`)
throw new Error(
`Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.`
)
}
const extractedRoot = await this.ensureLinuxAppImagePayload()
const status = extractedRoot
@@ -126,7 +128,9 @@ export class CliInstaller extends CliPathRegistration {
throw new Error(status.detail ?? 'CLI registration is unavailable on this build.')
}
if (status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`)
throw new Error(
`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`
)
}
// eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary.
+3 -1
View File
@@ -207,7 +207,9 @@ export class WslCliInstaller {
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
}
if (status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`)
throw new Error(
`Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.`
)
}
await this.run(
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import { validate } from '../telemetry/validator'
const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
() => ({
trackMock: vi.fn(),
accessSyncMock: vi.fn(),
existsSyncMock: vi.fn(() => true),
readFileSyncMock: vi.fn(),
getVersionMock: vi.fn(() => '1.4.191')
})
)
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
accessSync: accessSyncMock,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock
}))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
homedir: () => '/Users/alice'
}))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getVersion: getVersionMock })
}))
import {
classifyDaemonAdoptionOrigin,
trackDaemonAdopted,
trackDaemonPtyCwdDeniedIfDiverged
} from './daemon-adoption-telemetry-event'
const stalePidRecord: ParsedDaemonPid = {
pid: 1530,
startedAtMs: 1,
entryPath: '/x/daemon-entry.js',
appVersion: '1.4.187',
launchNonce: 'n',
linuxStartTicks: null,
bootId: null,
spawnerExecPath:
'/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca'
}
const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const
const PID_PATH = '/fake/daemon.pid'
beforeEach(() => {
trackMock.mockReset()
accessSyncMock.mockReset()
existsSyncMock.mockReset().mockReturnValue(true)
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('classifyDaemonAdoptionOrigin', () => {
it('compares the recorded app version and classifies the spawner path', () => {
expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({
app_version_match: 'same',
spawner_path_class: 'updater-cache'
})
expect(classifyDaemonAdoptionOrigin(null)).toEqual({
app_version_match: 'unknown',
spawner_path_class: 'unknown'
})
})
})
describe('trackDaemonAdopted', () => {
it('emits a validator-accepted payload', () => {
trackDaemonAdopted(stalePidRecord, 'intact', 7)
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_adopted')
expect(props).toEqual({
...origin,
tcc_attribution: 'intact',
live_session_count_bucket: '6+'
})
expect(validate('daemon_adopted', props).ok).toBe(true)
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow()
})
})
describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
it('emits only when the daemon was denied and the app can read the same cwd', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number))
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_pty_cwd_denied')
expect(props).toEqual({ cwd_class: 'documents', ...origin })
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
})
// False positives would drown the signal this event exists to measure, so every
// non-divergent shape must stay silent.
it('stays silent when the daemon could read the cwd or did not report', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent when the app cannot read the cwd either (no divergence)', () => {
accessSyncMock.mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
})
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(trackMock).not.toHaveBeenCalled()
})
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
readFileSyncMock.mockReturnValue(
JSON.stringify({
...stalePidRecord,
appVersion: '1.4.191',
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
})
)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
expect(trackMock.mock.calls[0][1]).toEqual({
cwd_class: 'documents',
app_version_match: 'same',
spawner_path_class: 'applications'
})
})
it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => {
getVersionMock.mockImplementationOnce(() => {
throw new Error('AppEnvironment not initialized')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent off macOS', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
})
})
@@ -0,0 +1,81 @@
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
import { accessSync, constants as fsConstants, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { getAppEnvironment } from '../../shared/app-environment'
import {
classifyDaemonPtyCwd,
classifyDaemonSpawnerPath,
type DaemonAdoptedAppVersionMatch,
type DaemonSpawnerPathClass
} from '../../shared/daemon-adoption-telemetry'
import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
export type DaemonAdoptionOrigin = Pick<
EventProps<'daemon_pty_cwd_denied'>,
'app_version_match' | 'spawner_path_class'
>
/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */
export function classifyDaemonAdoptionOrigin(
pidRecord: ParsedDaemonPid | null
): DaemonAdoptionOrigin {
const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion
? 'unknown'
: pidRecord.appVersion === getAppEnvironment().getVersion()
? 'same'
: 'different'
const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath(
pidRecord?.spawnerExecPath ?? null,
existsSync
)
return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass }
}
// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters).
export function trackDaemonAdopted(
pidRecord: ParsedDaemonPid | null,
tccAttribution: MacDaemonTccAttributionHealth,
liveSessionCount: number | null
): void {
try {
track('daemon_adopted', {
...classifyDaemonAdoptionOrigin(pidRecord),
tcc_attribution: tccAttribution,
live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount)
})
} catch {
// Telemetry is best-effort; a dropped event must not fail daemon adoption.
}
}
/**
* Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can
* read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape.
*/
export function trackDaemonPtyCwdDeniedIfDiverged(
cwd: string | undefined,
cwdReadableByDaemon: boolean | undefined,
pidPath: string | null
): void {
try {
if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) {
return
}
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
track('daemon_pty_cwd_denied', {
cwd_class: classifyDaemonPtyCwd(cwd, homedir()),
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
})
} catch {
// Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller.
}
}
@@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = {
wslDistro?: string | null
agentSessionEnsure?: AgentSessionClaimedSpawnResult
incarnationId?: PtyIncarnationId
/**
* Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own
* verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same
* path proves nothing about the daemon's (#17696). Omitted by daemons predating this field.
*/
cwdReadableByDaemon?: boolean
}
export function getDaemonSessionResultMetadata(session: {
+1 -1
View File
@@ -34,7 +34,7 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
@@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
// Why: both fakes are annotated with constructor types so the exported factories widen to
@@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
if (result.mode) {
this.handle.mode = result.mode
}
if (result.adopted) {
this.handle.adopted = true
}
return {
socketPath: result.socketPath,
tokenPath: result.tokenPath
@@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
trackDaemonReplaced: trackDaemonReplacedMock,
trackDaemonRetired: trackDaemonRetiredMock
}),
daemonAdoptionTelemetryEvent: () => ({
trackDaemonAdopted: trackDaemonAdoptedMock
}),
daemonSpawner: () => ({
DaemonSpawner: MockDaemonSpawner,
getDaemonSocketPath: (_dir: string, version?: number) =>
+3 -1
View File
@@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
vi.resetModules()
@@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
rebindLocalProviderListenersMock.mockClear()
trackDaemonReplacedMock.mockClear()
trackDaemonRetiredMock.mockClear()
trackDaemonAdoptedMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
@@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA
/** Handle the fake spawner hands back from ensureRunning/getHandle. */
export type MockSpawnerHandle = {
mode?: 'degraded-new-pty-fallback'
adopted?: true
releaseAdoptionLease?: () => void
shutdown: () => Promise<void>
}
@@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{
socketPath: string
tokenPath: string
mode?: 'degraded-new-pty-fallback'
adopted?: true
}>
/** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */
@@ -143,6 +145,7 @@ export type DaemonInitMockState = {
rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void>
trackDaemonReplacedMock: Mock<(...args: unknown[]) => void>
trackDaemonRetiredMock: Mock<(...args: unknown[]) => void>
trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void>
}
/** net.connect stubs the suites install in beforeEach. */
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
isPackagedMock,
getMacDaemonTccAttributionHealthMock,
trackDaemonAdoptedMock,
probeSocketExistsMock,
readFileSyncMock,
unlinkSyncMock,
@@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT
vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse())
vi.mock('./client', () => moduleFactories.client())
vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent())
vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent())
vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner())
vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter())
vi.mock('../ipc/pty', () => moduleFactories.ipcPty())
@@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
})
// #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so
// it gets its own event — macOS only, and only for adopted (not freshly forked) daemons.
it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' })
await mod.initDaemonPtyProvider()
await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce())
// null pid record: the harness has no pid file, which the emitter classifies as 'unknown'.
expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2)
vi.restoreAllMocks()
})
it('does not report adoption for a freshly forked daemon or off macOS', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
const linuxMod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
await linuxMod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
+3 -1
View File
@@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState {
const rebindLocalProviderListenersMock = vi.fn()
const trackDaemonReplacedMock = vi.fn()
const trackDaemonRetiredMock = vi.fn()
const trackDaemonAdoptedMock = vi.fn()
return {
getPathMock,
@@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
}
}
@@ -41,6 +41,7 @@ function createPreservedDaemonHandle(
mode?: 'degraded-new-pty-fallback'
): DaemonProcessHandle {
const handle: DaemonProcessHandle = {
adopted: true,
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
}
+31
View File
@@ -24,7 +24,10 @@ import {
import type { DaemonProvider } from './daemon-provider-routing'
import { installDaemonProvider } from './daemon-provider-state'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import { trackDaemonAdopted } from './daemon-adoption-telemetry-event'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import { trackDaemonRetired } from './daemon-lifecycle-event'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import { DaemonPtyRouter } from './daemon-pty-router'
@@ -156,9 +159,37 @@ export async function initDaemonPtyProvider(
logDaemonMilestone('daemon-init-done', {
legacyAdapters: legacyAdapters.length
})
if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) {
void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter)
}
await reconcileSeededClaudeLivePtys(routedAdapter)
}
// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup.
async function reportDaemonAdoption(
runtimeDir: string,
socketPath: string,
tokenPath: string,
adapter: DaemonPtyAdapter
): Promise<void> {
try {
const [tccAttribution, liveSessionCount] = await Promise.all([
getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath),
adapter.listSessions().then(
(sessions) => sessions.length,
() => null
)
])
trackDaemonAdopted(
readDaemonPidRecord(getDaemonPidPath(runtimeDir)),
tccAttribution,
liveSessionCount
)
} catch {
// Best-effort measurement only.
}
}
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
@@ -4,6 +4,7 @@ import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
@@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
}
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
if (result.isNew && !attachOnly) {
trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath)
}
return this.finishSpawn(context, result)
}
+2
View File
@@ -31,6 +31,8 @@ export type DaemonPidFile = {
export type DaemonProcessHandle = {
mode?: 'degraded-new-pty-fallback'
/** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */
adopted?: true
releaseAdoptionLease?(): void
shutdown(): Promise<void>
}
+4 -1
View File
@@ -161,7 +161,10 @@ export class DaemonTerminalAdmission {
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(result.cwdReadableByDaemon !== undefined
? { cwdReadableByDaemon: result.cwdReadableByDaemon }
: {})
}
}
@@ -0,0 +1,20 @@
import { TerminalAttachCanceledError } from './daemon-errors'
/** Never resolves; only rejects, so it can bound a wait without settling it. */
export function rejectOnAbort(
signal: AbortSignal | undefined,
sessionId: string
): Promise<never> {
if (!signal) {
return new Promise<never>(() => {})
}
return new Promise<never>((_resolve, reject) => {
if (signal.aborted) {
reject(new TerminalAttachCanceledError(sessionId))
return
}
signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), {
once: true
})
})
}
@@ -54,4 +54,6 @@ export type CreateOrAttachResult = {
attachToken: symbol
incarnationId: PtyIncarnationId
agentSessionEnsure?: AgentSessionClaimedSpawnResult
/** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */
cwdReadableByDaemon?: boolean
}
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExitCb?.(0), 5)
}),
terminateOwnedTree: () => 'unavailable' as const,
forceKill: vi.fn(() => onExitCb?.(137)),
signal: vi.fn(),
onData() {},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn()
}
}
// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict
// rides on the create result. A non-permission failure must never read as denial.
describe('TerminalHost cwd readability verdict', () => {
let host: TerminalHost
let platformDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess()
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
await host.dispose()
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
})
const create = (sessionId: string, cwd?: string) =>
host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
...(cwd ? { cwd } : {}),
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
it('reports a readable cwd as readable', async () => {
expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true)
})
it('reports a missing cwd as readable — absence is not a permission denial', async () => {
expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
})
it('omits the verdict when no cwd was requested', async () => {
expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined()
})
it('omits the verdict on attach to an existing session', async () => {
await create('attach', process.cwd())
const attached = await create('attach', process.cwd())
expect(attached.isNew).toBe(false)
expect(attached.cwdReadableByDaemon).toBeUndefined()
})
})
@@ -1,3 +1,4 @@
import { accessSync, constants as fsConstants } from 'node:fs'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
@@ -11,11 +12,14 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones'
import type { TerminalSessionTeardown } from './terminal-session-teardown'
import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window'
import { TerminalAttachCanceledError } from './daemon-errors'
import { rejectOnAbort } from './terminal-attach-cancellation'
import { SessionNotFoundError } from './types'
import { resolveWslSessionContext } from './wsl-session-context'
type TerminalHostSessionCreateDependencies = {
sessions: Map<string, Session>
/** Re-checks the host's shutdown fence and this request's cancellation after any await. */
assertCreateAllowed: () => void
sessionTeardown: TerminalSessionTeardown
killedTombstones: TerminalHostTombstones
spawnSubprocess: TerminalHostOptions['spawnSubprocess']
@@ -30,12 +34,29 @@ export async function createOrAttachTerminalSession(
deps: TerminalHostSessionCreateDependencies
): Promise<CreateOrAttachResult> {
opts.onSessionResolved?.(opts.sessionId)
const existing = deps.sessions.get(opts.sessionId)
let existing = deps.sessions.get(opts.sessionId)
// Why: descendant capture must finish before attach or recreation, or the
// caller could receive a doomed session while teardown owns its process.
if (deps.sessionTeardown.get(opts.sessionId) || existing?.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
// An attach must not adopt a doomed session; its caller retires the pane and respawns.
if (opts.attachOnly) {
throw new SessionNotFoundError(opts.sessionId)
}
// A create can wait teardown out instead, and must: a pane respawning onto its own stable id
// reaches this a beat after the attach that retired it, and refusing surfaced the raw
// SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell
// sweep holds the claim across an OS identity probe and taskkill (#18046).
await Promise.race([
deps.sessionTeardown.settle(opts.sessionId),
rejectOnAbort(opts.cancelSignal, opts.sessionId)
])
deps.assertCreateAllowed()
existing = deps.sessions.get(opts.sessionId)
// Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate.
if (existing?.isAlive && existing.isTerminating) {
throw new SessionNotFoundError(opts.sessionId)
}
}
// Why no ownership settle here: attach is synchronous by contract. A viewer
@@ -88,6 +109,8 @@ async function spawnAndPublishSession(
ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }
): Promise<CreateOrAttachResult> {
const { size, wslDistro } = ctx
// Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path.
const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null
const subprocess = await deps.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
@@ -184,6 +207,20 @@ async function spawnAndPublishSession(
shellState: session.shellState,
incarnationId: session.incarnationId,
...getDaemonSessionResultMetadata(session),
...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}),
attachToken: token
}
}
// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what
// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never
// masquerade as a permission denial.
function isCwdReadableByThisProcess(cwd: string): boolean {
try {
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
return code !== 'EACCES' && code !== 'EPERM'
}
}
@@ -0,0 +1,151 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
// Why mocked: the win32 plain-shell teardown sweeps for real, and an unmocked run would put a
// live process-table probe -- and, on a recycled pid, a taskkill /T /F -- behind these tests.
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
type SpawnSubprocess = TerminalHostOptions['spawnSubprocess']
type ExitableSubprocess = SubprocessHandle & { exit: (code: number) => void }
/** Shells that report their exit only after `exitDelayMs`, holding the teardown claim open the
* way a real one does while the Windows sweep probes and taskkills its tree. Collected so a test
* can retire an intentionally unkillable child instead of leaking its exit waiter. */
function spawnSubprocessWithSlowExit(exitDelayMs: number): {
spawnSubprocess: Mock<SpawnSubprocess>
handles: ExitableSubprocess[]
} {
const handles: ExitableSubprocess[] = []
const spawnSubprocess = vi.fn<SpawnSubprocess>(() => {
let onExit: ((code: number) => void) | undefined
const handle = {
pid: 4242,
exit: (code: number) => onExit?.(code),
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExit?.(0), exitDelayMs).unref?.()
}),
terminateOwnedTree: () => 'unavailable' as const,
forceKill: vi.fn(() => {
setTimeout(() => onExit?.(137), exitDelayMs).unref?.()
}),
signal: vi.fn(),
onData: vi.fn(),
onExit: vi.fn((callback) => {
onExit = callback
}),
dispose: vi.fn()
} as unknown as ExitableSubprocess
handles.push(handle)
return handle
})
return { spawnSubprocess, handles }
}
const streamClient = (): { onData: Mock; onExit: Mock } => ({
onData: vi.fn(),
onExit: vi.fn()
})
describe('TerminalHost recreate during teardown', () => {
it('recreates a session whose id is still being torn down', async () => {
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
const host = new TerminalHost({ spawnSubprocess })
const sessionId = 'wt-1@@respawning-pane'
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
// The pane closes and immediately respawns onto its own stable id (#18046).
const killed = host.kill(sessionId, { immediate: true })
const recreated = await host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
streamClient: streamClient()
})
expect(recreated.isNew).toBe(true)
expect(spawnSubprocess).toHaveBeenCalledTimes(2)
await killed
await host.dispose()
})
it('still refuses an attach-only respawn onto a session being torn down', async () => {
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
const host = new TerminalHost({ spawnSubprocess })
const sessionId = 'wt-1@@attaching-pane'
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
const killed = host.kill(sessionId, { immediate: true })
// Why unchanged: adopting a doomed session would hand the pane a shell teardown owns; the
// caller retires the pane binding on this error and spawns fresh.
await expect(
host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
attachOnly: true,
streamClient: streamClient()
})
).rejects.toThrow(`Session not found: ${sessionId}`)
expect(spawnSubprocess).toHaveBeenCalledOnce()
await killed
await host.dispose()
})
it('refuses a create waiting on teardown once the host is shutting down', async () => {
const { spawnSubprocess } = spawnSubprocessWithSlowExit(40)
const host = new TerminalHost({ spawnSubprocess })
const sessionId = 'wt-1@@shutting-down-pane'
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
const killed = host.kill(sessionId, { immediate: true })
const create = host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
streamClient: streamClient()
})
// Why: dispose joins pending creations, so a create that waited out teardown must re-read the
// fence rather than publish a session nothing will shut down.
const disposed = host.dispose()
await expect(create).rejects.toThrow('Terminal host is shutting down')
expect(spawnSubprocess).toHaveBeenCalledOnce()
await killed
await disposed
})
it('leaves a canceled create waiting on teardown instead of the full exit budget', async () => {
// Why a child that never exits on its own: the create must leave on its abort signal, not on
// the teardown settling, so the teardown deliberately outlives the assertion.
const { spawnSubprocess, handles } = spawnSubprocessWithSlowExit(30_000)
const host = new TerminalHost({ spawnSubprocess })
const sessionId = 'wt-1@@canceled-pane'
await host.createOrAttach({ sessionId, cols: 80, rows: 24, streamClient: streamClient() })
const killed = host.kill(sessionId, { immediate: true })
const canceled = new AbortController()
const create = host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
cancelSignal: canceled.signal,
isCanceled: () => canceled.signal.aborted,
streamClient: streamClient()
})
canceled.abort()
await expect(create).rejects.toThrow(`Attach canceled for session ${sessionId}`)
expect(spawnSubprocess).toHaveBeenCalledOnce()
handles[0].exit(137)
await killed
await host.dispose()
})
})
+45 -27
View File
@@ -473,14 +473,17 @@ describe('TerminalHost', () => {
expect(lastSubprocess.forceKill).toHaveBeenCalledTimes(1)
expect(lastSubprocess.dispose).not.toHaveBeenCalled()
expect(host.listSessions()).toHaveLength(1)
await expect(
host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found')
// An unkillable child never releases the id: the create waits out its own budget and
// then reports absence rather than publishing a session teardown still owns.
const recreate = host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const refused = expect(recreate).rejects.toThrow('Session not found')
await vi.advanceTimersByTimeAsync(IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS)
await refused
lastSubprocess._onExitCb?.(137)
expect(host.listSessions()).toHaveLength(0)
@@ -525,7 +528,7 @@ describe('TerminalHost', () => {
expect(lastSubprocess.dispose).toHaveBeenCalled()
})
it('rejects reattach while an agent immediate-kill snapshot is pending', async () => {
it('defers a respawn until the agent immediate-kill snapshot completes', async () => {
let finishSweep!: () => void
killWithDescendantSweepMock.mockImplementation(
(_pid: number, finish: () => void) =>
@@ -544,22 +547,35 @@ describe('TerminalHost', () => {
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const retiredSubprocess = lastSubprocess
const killing = host.kill('agent-reattach', { immediate: true })
await expect(
host.createOrAttach({
let respawned = false
const respawn = host
.createOrAttach({
sessionId: 'agent-reattach',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found')
expect(lastSubprocess.forceKill).not.toHaveBeenCalled()
.then((result) => {
respawned = true
return result
})
await Promise.resolve()
await Promise.resolve()
// Why it must not resolve yet: capture still owns the process, so publishing here would
// hand the caller a session teardown is about to kill.
expect(respawned).toBe(false)
expect(spawnFn).toHaveBeenCalledTimes(1)
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
finishSweep()
lastSubprocess._onExitCb?.(137)
retiredSubprocess._onExitCb?.(137)
await killing
expect(lastSubprocess.forceKill).toHaveBeenCalledOnce()
await expect(respawn).resolves.toMatchObject({ isNew: true })
expect(retiredSubprocess.forceKill).toHaveBeenCalledOnce()
})
it('coalesces duplicate immediate kill while descendant capture is pending', async () => {
@@ -606,29 +622,31 @@ describe('TerminalHost', () => {
const killing = host.kill('agent-natural-exit', { immediate: true })
retiredSubprocess._onExitCb?.(0)
await expect(
host.createOrAttach({
let respawned = false
const respawn = host
.createOrAttach({
sessionId: 'agent-natural-exit',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).rejects.toThrow('Session not found')
.then((result) => {
respawned = true
return result
})
await Promise.resolve()
await Promise.resolve()
// The root is already reaped, but the scan still holds the id.
expect(respawned).toBe(false)
expect(spawnFn).toHaveBeenCalledTimes(1)
completeSweep()
await killing
expect(retiredSubprocess.forceKill).not.toHaveBeenCalled()
await expect(
host.createOrAttach({
sessionId: 'agent-natural-exit',
cols: 80,
rows: 24,
launchAgent: 'claude',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
).resolves.toEqual(expect.objectContaining({ isNew: true }))
await expect(respawn).resolves.toEqual(expect.objectContaining({ isNew: true }))
expect(spawnFn).toHaveBeenCalledTimes(2)
})
+2 -15
View File
@@ -21,24 +21,10 @@ import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
import { isShellProcess } from '../../shared/agent-detection'
import { TerminalAttachCanceledError } from './daemon-errors'
import { rejectOnAbort } from './terminal-attach-cancellation'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
/** Never resolves; only rejects, so it can bound a wait without settling it. */
function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise<never> {
if (!signal) {
return new Promise<never>(() => {})
}
return new Promise<never>((_resolve, reject) => {
if (signal.aborted) {
reject(new TerminalAttachCanceledError(sessionId))
return
}
signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), {
once: true
})
})
}
export type { TerminalHostOptions } from './terminal-host-options'
const DEFAULT_MAX_TOMBSTONES = 1000
@@ -106,6 +92,7 @@ export class TerminalHost {
}
return await createOrAttachTerminalSession(options, {
sessions: this.sessions,
assertCreateAllowed: () => this.assertCreateOrAttachAllowed(options),
sessionTeardown: this.sessionTeardown,
killedTombstones: this.killedTombstones,
spawnSubprocess: this.spawnSubprocess,
+69 -46
View File
@@ -1,7 +1,7 @@
import { killWithDescendantSweep } from '../pty-descendant-termination'
import type { Session } from './session'
type AgentTeardownOperation = {
type TeardownOperation = {
promise: Promise<void>
immediate: boolean
rootSignalled: boolean
@@ -9,10 +9,10 @@ type AgentTeardownOperation = {
session: Session
}
/** Owns agent teardown by session id until descendant capture and root
* signalling finish, even when the root exits and its Session is reaped. */
/** Owns teardown by session id until descendant capture and root signalling
* finish, even when the root exits and its Session is reaped. */
export class TerminalSessionTeardown {
private operations = new Map<string, AgentTeardownOperation>()
private operations = new Map<string, TeardownOperation>()
constructor(private sessions: ReadonlyMap<string, Session>) {}
@@ -20,6 +20,12 @@ export class TerminalSessionTeardown {
return this.operations.get(sessionId)?.promise
}
/** Resolves once this id's tracked teardown has released the process — a rejected teardown
* released it too. Callers re-read session state afterwards and decide for themselves. */
async settle(sessionId: string): Promise<void> {
await this.operations.get(sessionId)?.promise.catch(() => {})
}
requestImmediate(sessionId: string): Promise<void> | undefined {
const pending = this.operations.get(sessionId)
if (pending) {
@@ -38,11 +44,42 @@ export class TerminalSessionTeardown {
return this.killAgentSession(sessionId, session, immediate)
}
if (immediate) {
return this.forceKillPlainShellSession(sessionId, session)
// Why tracked like the agent path: this claims termination on the Session and then awaits
// an OS probe and taskkill, and a create landing inside that window must be able to wait it
// out rather than be told the id is absent (#18046).
return this.track(sessionId, session, immediate, () =>
this.forceKillPlainShellSession(sessionId, session)
)
}
session.kill()
}
/** Publishes an operation for `sessionId` and retires it once the teardown settles. */
private track(
sessionId: string,
session: Session,
immediate: boolean,
run: (entry: TeardownOperation) => Promise<void>
): Promise<void> {
const entry: TeardownOperation = {
promise: Promise.resolve(),
immediate,
rootSignalled: false,
rootCompletion: Promise.resolve(),
session
}
const operation = run(entry)
entry.promise = operation
this.operations.set(sessionId, entry)
const clearOperation = (): void => {
if (this.operations.get(sessionId) === entry) {
this.operations.delete(sessionId)
}
}
void operation.then(clearOperation, clearOperation)
return operation
}
/**
* Immediate teardown of a non-agent shell. On Windows, closing the ConPTY does not
* reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a
@@ -92,48 +129,34 @@ export class TerminalSessionTeardown {
session.scheduleForceDisposeFallback()
}
const entry: AgentTeardownOperation = {
promise: Promise.resolve(),
immediate,
rootSignalled: false,
rootCompletion: Promise.resolve(),
session
}
const sweep = Promise.resolve(
killWithDescendantSweep(
session.pid,
() => {
// Why: natural exit reaps the PID while ps is running. Never signal that
// stale numeric PID after the Session no longer represents a live root.
if (!session.isAlive) {
return
return this.track(sessionId, session, immediate, (entry) => {
const sweep = Promise.resolve(
killWithDescendantSweep(
session.pid,
() => {
// Why: natural exit reaps the PID while ps is running. Never signal that
// stale numeric PID after the Session no longer represents a live root.
if (!session.isAlive) {
return
}
entry.rootSignalled = true
if (entry.immediate) {
entry.rootCompletion = session.forceKillAndWaitForExit()
} else {
session.signalTerminationRoot()
}
},
{
// Why: the descendant rows are only authoritative while this exact
// Session still owns the root PID captured by ps.
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive,
terminateOwnedTree: () => session.terminateOwnedTree()
}
entry.rootSignalled = true
if (entry.immediate) {
entry.rootCompletion = session.forceKillAndWaitForExit()
} else {
session.signalTerminationRoot()
}
},
{
// Why: the descendant rows are only authoritative while this exact
// Session still owns the root PID captured by ps.
ownsRoot: () => this.sessions.get(sessionId) === session && session.isAlive,
terminateOwnedTree: () => session.terminateOwnedTree()
}
)
)
)
// Why: descendant capture completion only proves signals were requested;
// destructive callers must retain the native owner until OS-confirmed exit.
const operation = sweep.then(() => entry.rootCompletion)
entry.promise = operation
this.operations.set(sessionId, entry)
const clearOperation = (): void => {
if (this.operations.get(sessionId) === entry) {
this.operations.delete(sessionId)
}
}
void operation.then(clearOperation, clearOperation)
return operation
// Why: descendant capture completion only proves signals were requested;
// destructive callers must retain the native owner until OS-confirmed exit.
return sweep.then(() => entry.rootCompletion)
})
}
}
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('./runner', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
gitExecFileAsync: gitExecFileAsyncMock
}))
import {
_resetCanonicalRepoKeyCacheForTests,
getCanonicalRepoKey,
readGitCommonDir
} from './canonical-repo-key'
beforeEach(() => {
_resetCanonicalRepoKeyCacheForTests()
gitExecFileAsyncMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('readGitCommonDir', () => {
it('reads the absolute answer modern Git gives', () => {
expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git')
})
it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => {
// Without this every repository on such a host would answer `.git` and collide.
expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git')
})
it('resolves a WSL answer in Git execution space, not against the UNC path', () => {
expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git')
})
it('tolerates CRLF and blank lines', () => {
expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git')
})
it('returns undefined when Git printed nothing usable', () => {
expect(readGitCommonDir('\n', '/repo')).toBeUndefined()
})
})
describe('getCanonicalRepoKey', () => {
it('gives every worktree of one repository the same key', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' })
await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git')
await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git')
})
it('scopes the key to the execution host', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' })
await expect(
getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' })
).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git')
})
it('caches so repeated arming costs no subprocess', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' })
await getCanonicalRepoKey('/repo')
await getCanonicalRepoKey('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('falls back to the caller path when Git cannot answer', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository'))
await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo')
})
})
+72
View File
@@ -0,0 +1,72 @@
import { toWslExecutionSpace } from '../../shared/wsl-paths'
import { gitExecFileAsync } from './runner'
import { resolveRevParsePath } from './worktree-path-comparison'
/**
* One repository on one execution host, named by its Git common dir.
*
* Shared by the fetch controller (which serializes fetches on it) and idle ref
* maintenance (which scopes all of its state to it), so both agree on what "the
* same repo" means across every worktree that points at it.
*/
export type CanonicalRepoKeyOptions = { wslDistro?: string }
const CACHE_MAX = 512
const cache = new Map<string, string>()
/**
* Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag,
* exits 0, and prints a relative `.git`. Taking the raw stdout there would give
* every repository on the host the same key.
*/
export function readGitCommonDir(stdout: string, repoPath: string): string | undefined {
const commonDir = stdout
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.findLast((line) => line.length > 0 && !line.startsWith('-'))
return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined
}
function remember(cacheKey: string, value: string): string {
cache.delete(cacheKey)
cache.set(cacheKey, value)
while (cache.size > CACHE_MAX) {
const oldest = cache.keys().next()
if (oldest.done) {
break
}
cache.delete(oldest.value)
}
return value
}
/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */
export async function getCanonicalRepoKey(
repoPath: string,
options: CanonicalRepoKeyOptions = {}
): Promise<string> {
const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local'
const cacheKey = `${runtimeKey}::${repoPath}`
const cached = cache.get(cacheKey)
if (cached !== undefined) {
return remember(cacheKey, cached)
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--git-common-dir'],
{ cwd: repoPath, ...options }
)
const commonDir = readGitCommonDir(stdout, repoPath)
if (commonDir) {
return remember(cacheKey, `${runtimeKey}::${commonDir}`)
}
} catch {
// The caller path remains a safe serialization key when canonicalization fails.
}
return remember(cacheKey, cacheKey)
}
export function _resetCanonicalRepoKeyCacheForTests(): void {
cache.clear()
}
+49
View File
@@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = {
type ExactRefPresence = 'present' | 'absent' | 'unknown'
const EXACT_REF_PROBE_CONCURRENCY = 8
// SHA-1 and SHA-256 repositories both report a full object id here.
const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
export function isShowRefNoMatchError(error: unknown): boolean {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
@@ -126,3 +128,50 @@ export async function probeAnyExactRef(
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
return { found, unknown }
}
/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */
export type ExactRefProbeStdinExec = (
argv: string[],
options: ExactRefProbeExecOptions & { stdin: string }
) => Promise<{ stdout: string }>
/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data
* rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`.
* A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */
export async function probeAnyExactRefBatched(
runGit: ExactRefProbeStdinExec,
refs: readonly string[],
options: ExactRefProbeExecOptions = {}
): Promise<{ found: boolean; unknown: boolean }> {
const uniqueRefs = [...new Set(refs)]
const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref))
if (safeRefs.length === 0) {
return { found: false, unknown: uniqueRefs.length > 0 }
}
let stdout: string
try {
;({ stdout } = await runGit(['cat-file', '--batch-check'], {
...options,
stdin: `${safeRefs.join('\n')}\n`
}))
} catch {
return { found: false, unknown: true }
}
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
// One line per input, in order; a short read means the batch never answered for the rest.
if (lines.length !== safeRefs.length) {
return { found: false, unknown: true }
}
let unknown = safeRefs.length !== uniqueRefs.length
for (const line of lines) {
const [head, type] = line.split(' ')
if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') {
return { found: true, unknown: false }
}
if (type !== 'missing') {
// `ambiguous`, or a spelling this Git reports differently; neither proves absence.
unknown = true
}
}
return { found: false, unknown }
}
+24
View File
@@ -55,6 +55,30 @@ function refspecSource(refspec: string): string {
return refspec.replace(/^\+/, '').split(':')[0]!
}
/**
* True if `branchName`'s remote-tracking ref already exists locally under `remoteName`.
* Used to skip a redundant fetch on the common repeat-materialize case (the ref was
* already pulled in by an earlier mint/fetch) while still fetching it on demand the
* first time a sibling worktree widens an existing remote onto a new branch -- a bare
* refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`).
*/
export async function forkRemoteTrackingRefExists(
execGit: GitExecFn,
repoPath: string,
remoteName: string,
branchName: string
): Promise<boolean> {
try {
await execGit(
['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`],
repoPath
)
return true
} catch {
return false
}
}
/**
* True only if `remote.<name>.url` is actually set. Deliberately plumbing (`config --get`),
* not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name*
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn())
vi.mock('./runner', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('./worktree-list-reader', async (importOriginal) => ({
...((await importOriginal()) as Record<string, unknown>),
readRepoCommonDirFromGit: readRepoCommonDirFromGitMock
}))
import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key'
import {
_resetLocalRepoRefMaintenanceForTests,
armLocalRepoRefMaintenance,
createLocalRepoRefMaintenanceTarget,
getLocalRepoRefMaintenance,
setRepoMaintenanceActivityProbe,
withRepoRefMaintenancePaused
} from './local-repo-ref-maintenance'
const NO_ABORT = new AbortController().signal
function target(wslDistro?: string): ReturnType<typeof createLocalRepoRefMaintenanceTarget> {
return createLocalRepoRefMaintenanceTarget({
key: 'local::/repo/.git',
repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo',
...(wslDistro ? { wslDistro } : {})
})
}
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
readRepoCommonDirFromGitMock.mockReset()
delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE
_resetCanonicalRepoKeyCacheForTests()
_resetLocalRepoRefMaintenanceForTests()
})
afterEach(() => {
delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE
_resetLocalRepoRefMaintenanceForTests()
vi.restoreAllMocks()
})
describe('local repo ref maintenance target', () => {
it('never hands the pack child an abort signal', async () => {
// Killing a `pack-refs` strands a `refs/**` lock about one time in five, and
// on Windows a force-kill inside the rewrite strands `packed-refs.lock`
// every time. The child must always be allowed to finish.
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await target().packRefs({ setHeld: () => {} })
const packCall = gitExecFileAsyncMock.mock.calls.find(
([argv]) => (argv as string[])[0] === 'pack-refs'
)
expect(packCall?.[1]).not.toHaveProperty('signal')
})
it('runs pack-refs at the background tier with a long deadline', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await target().packRefs({ setHeld: () => {} })
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['pack-refs', '--all', '--prune'],
expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 })
)
})
it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => {
for (const stdout of [
'maintenance.auto false\n',
'gc.auto 0\n',
'gc.auto 6700\nmaintenance.auto false\n'
]) {
gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' })
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true)
}
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'maintenance.auto true\ngc.auto 6700\n',
stderr: ''
})
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
// `git config --get-regexp` exits non-zero when nothing matches.
gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1'))
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
})
it('walks the POSIX refs directory for a native repo', async () => {
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs')
})
it('translates a WSL repo answer back to the UNC path the main process can open', async () => {
// Git answers in its own execution space, which for WSL is a Linux path.
readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git')
await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe(
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs'
)
})
it('reports an unresolvable repository rather than guessing a path', async () => {
readRepoCommonDirFromGitMock.mockResolvedValue(undefined)
await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined()
})
})
describe('local repo ref maintenance scheduling', () => {
it('schedules nothing when the kill switch is set', () => {
process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1'
const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm')
armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' })
expect(arm).not.toHaveBeenCalled()
})
it('arms through the shared single-flight instance otherwise', () => {
const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm')
armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' })
expect(arm).toHaveBeenCalledTimes(1)
})
it('is free when nothing has ever been armed', async () => {
// The common case by far: no timers, no instance, no reason to pay anything.
await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe(
'done'
)
})
it('holds the window shut for the duration of ref-touching work', async () => {
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 })
setRepoMaintenanceActivityProbe(() => false)
const maintenance = getLocalRepoRefMaintenance()
const packRefs = vi.fn(async () => {})
maintenance.arm({
key: 'local::/repo/.git',
resolveRefsDirectory: async () => '/repo/.git/refs',
packRefs
})
await withRepoRefMaintenancePaused('branch-delete', async () => {
await new Promise((resolve) => setTimeout(resolve, 25))
expect(packRefs).not.toHaveBeenCalled()
})
await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1))
})
it('routes the app activity probe into the shared instance', async () => {
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
let busy = true
setRepoMaintenanceActivityProbe(() => busy)
const maintenance = getLocalRepoRefMaintenance()
const packRefs = vi.fn(async () => {})
maintenance.arm({
key: 'local::/repo/.git',
resolveRefsDirectory: async () => '/repo/.git/refs',
packRefs
})
await maintenance.whenAttemptSettled()
expect(packRefs).not.toHaveBeenCalled()
busy = false
})
})
+272
View File
@@ -0,0 +1,272 @@
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
import {
PACK_REFS_ARGS,
PACK_REFS_TIMEOUT_MS,
RefMaintenanceRepoLocked,
type PackedRefsLockReporter,
type RepoRefMaintenanceOptions,
type RepoRefMaintenanceTarget
} from '../../shared/repo-ref-maintenance-policy'
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
import { withSpan } from '../observability/tracer'
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
import { gitExecFileAsync } from './runner'
import { readRepoCommonDirFromGit } from './worktree-list-reader'
/**
* Main-process wiring for idle loose-ref packing on the local execution host
* (native and WSL).
*
* SSH-hosted repos are deliberately out of scope: the execution host owns
* anything that touches execution, so maintaining them means running host-side
* on the relay, which today has neither admission control nor spans. Keying all
* state by execution host is what keeps this path from reaching across.
*/
export type RepoMaintenanceActivityProbe = () => boolean
const REPO_BUSY_PROBE_MAX = 64
let activityProbe: RepoMaintenanceActivityProbe | null = null
let shared: RepoRefMaintenance | null = null
// Why keyed here rather than captured in the target: a repo can be armed from
// the fetch controller or from a user-initiated fetch, and every arming must see
// the same "this repo has work in flight" answer, not whichever closure was last.
const repoBusyProbes = new Map<string, () => boolean>()
/** Register the owner of "this repo has a fetch in flight" for `key`. */
export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void {
repoBusyProbes.delete(key)
repoBusyProbes.set(key, probe)
while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) {
const oldest = repoBusyProbes.keys().next()
if (oldest.done) {
break
}
repoBusyProbes.delete(oldest.value)
}
}
/**
* Register the app-wide "do not start maintenance now" signal. Owned by the
* main entry point because the inputs (live agents, battery, quit) are not
* visible from the git layer.
*/
export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void {
activityProbe = probe
}
/** Support escape hatch: kills the sweep without touching the user's git config. */
function isDisabled(): boolean {
return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1'
}
function localMaintenanceOptions(): RepoRefMaintenanceOptions {
return {
// Fail closed: without the app-level gate installed we cannot see agents,
// creates, or battery, and running blind is worse than not running.
isBusy: () => activityProbe?.() ?? true,
observe: (attempt) =>
withSpan('repo.ref_maintenance', (span) => attempt(span), {
attributes: { kind: 'git', 'repo.maintenance_host': 'local' }
}),
onError: (error) => {
console.warn('[repo-ref-maintenance] attempt failed:', error)
}
}
}
export function getLocalRepoRefMaintenance(): RepoRefMaintenance {
shared ??= new RepoRefMaintenance(localMaintenanceOptions())
return shared
}
/**
* Cancels every armed timer and waits out any `packed-refs` rewrite in progress.
*
* Deliberately does not kill the child. A pack orphaned by the app quitting
* finishes on its own; a pack signalled mid-prune strands a ref lock about one
* time in five, and on Windows a force-kill inside the rewrite strands
* `packed-refs.lock` every time -- which blocks every later ref deletion.
*/
export function disposeLocalRepoRefMaintenance(): Promise<void> {
const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve()
shared?.dispose()
shared = null
repoBusyProbes.clear()
return settling
}
/**
* Hold every repository open while `run` touches refs.
*
* A ref deletion needs `packed-refs.lock`, which a running pack holds only while
* it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the
* collision into a short pause. Cancelling the pack instead would strand a
* `refs/**` lock about one time in five, which Git never clears, so the ref
* stays undeletable indefinitely.
*/
export async function withRepoRefMaintenancePaused<T>(
reason: string,
run: () => Promise<T>
): Promise<T> {
// Taken unconditionally rather than only when something is already armed: a
// fetch inside `run` can arm the sweep, and one counter bump against an idle
// instance costs a microtask. This can rebuild the instance after the
// quit-time dispose; harmless, because a fresh one has no armed timers and its
// activity probe is gone, so it fails closed.
const release = await getLocalRepoRefMaintenance().pause(reason)
try {
return await run()
} finally {
release()
}
}
/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */
export function awaitPackedRefsLockRelease(): Promise<void> {
return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve()
}
/**
* Count user-initiated ref work as activity and restart every armed countdown.
*
* Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a
* path the user is waiting on, and a manual fetch or pull says the user is at
* the keyboard, which is a reason to defer every repository.
*/
export function postponeRepoRefMaintenance(): void {
shared?.postponeAll()
}
/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */
export function _resetLocalRepoRefMaintenanceForTests(
overrides?: Partial<RepoRefMaintenanceOptions>
): void {
shared?.dispose()
shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null
activityProbe = null
repoBusyProbes.clear()
}
/**
* Git reports the common dir in its own execution space, so a WSL repo answers
* with a Linux path the Windows main process cannot open. Translate it back to
* the UNC spelling for the dirent walk; the walk reads directories, not files,
* so the handful of round trips stays cheap even over the share.
*/
function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string {
if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) {
return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs')
}
// Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too.
return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs')
}
/**
* `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for
* to tell Git to stop maintaining a repository on its own. Orca sets both on its
* own fetches, but only as per-invocation `-c` flags, so this probe sees the
* user's persisted config and never Orca's own suppression.
*/
export function isGitAutoMaintenanceDisabled(configOutput: string): boolean {
return configOutput
.split('\n')
.map((line) => line.trim())
.some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0')
}
/**
* The common dir in the spelling the main process can open.
*
* Derived from the converted refs path, not the raw one: a WSL answer arrives as
* a Linux path but converts to a UNC path with no `/` in it, so choosing the
* path flavour before conversion collapses the whole thing to `.`.
*/
function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string {
const refs = refsDirectoryForMainProcess(commonDir, wslDistro)
return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs)
}
export type LocalRepoRefMaintenanceTargetArgs = {
/** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */
readonly key: string
readonly repoPath: string
readonly wslDistro?: string
}
/**
* Record a write to this repo and restart its quiet-period countdown. The only
* entry point callers need: the kill switch is honoured before anything is
* scheduled, so a disabled build arms no timers at all.
*/
export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void {
if (isDisabled()) {
return
}
getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args))
}
export function createLocalRepoRefMaintenanceTarget(
args: LocalRepoRefMaintenanceTargetArgs
): RepoRefMaintenanceTarget {
const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {}
// The engine always probes before it packs, so the pack reuses this answer
// rather than spending a second rev-parse on the same repository.
let commonDir: string | undefined
const resolveCommonDir = async (signal?: AbortSignal): Promise<string | undefined> => {
commonDir ??= await readRepoCommonDirFromGit(args.repoPath, {
...gitOptions,
...(signal ? { signal } : {})
})
return commonDir
}
return {
key: args.key,
isBusy: () => repoBusyProbes.get(args.key)?.() ?? false,
async resolveRefsDirectory(signal: AbortSignal) {
const resolved = await resolveCommonDir(signal)
return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined
},
async isOptedOut(signal: AbortSignal) {
try {
const { stdout } = await gitExecFileAsync(
['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'],
{ cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal }
)
return isGitAutoMaintenanceDisabled(stdout)
} catch {
// Neither key set is the common case and exits non-zero; that is consent.
return false
}
},
async packRefs(lock: PackedRefsLockReporter) {
const resolved = await resolveCommonDir()
const owner = resolved
? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro))
: null
const claim = owner ? await owner.claim() : { ok: true as const }
if (!claim.ok) {
throw new RefMaintenanceRepoLocked(claim.reason)
}
// Report the rewrite window rather than accepting a signal. A pack that is
// killed mid-prune strands a `refs/**` lock about one time in five, and
// Git never clears those; waiting out the window costs at most ~1.4s.
const watch = owner?.watchLock((held) => lock.setHeld(held))
try {
await gitExecFileAsync([...PACK_REFS_ARGS], {
cwd: args.repoPath,
...gitOptions,
admissionTier: 'background',
timeout: PACK_REFS_TIMEOUT_MS
})
} finally {
watch?.stop()
lock.setHeld(false)
await owner?.release()
}
}
}
}
@@ -0,0 +1,192 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
const roots: string[] = []
async function gitCommonDir(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-'))
roots.push(root)
return root
}
function paths(commonDir: string): { lock: string; marker: string } {
return {
lock: join(commonDir, 'packed-refs.lock'),
marker: join(commonDir, 'packed-refs.orca-owner')
}
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
/** A pid that cannot be running: the kernel rejects it outright. */
const DEAD_PID = 0x7fffffff
const ABANDONED_LOCK_AGE_MS = 15 * 60_000
const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000
/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */
function laterBy(ms: number): number {
return Date.now() + ms
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('packed-refs lock ownership', () => {
it('claims a repository with no lock and records the owner', async () => {
const commonDir = await gitCommonDir()
const { marker } = paths(commonDir)
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true })
await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid))
})
it('drops the owner marker on release', async () => {
const commonDir = await gitCommonDir()
const ownership = new PackRefsLockOwnership(commonDir)
await ownership.claim()
await ownership.release()
await expect(exists(paths(commonDir).marker)).resolves.toBe(false)
})
it('refuses a lock it cannot prove is its own', async () => {
const commonDir = await gitCommonDir()
// A lock with no marker belongs to the user's own git, or to another tool.
await writeFile(paths(commonDir).lock, 'someone else')
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false })
await expect(exists(paths(commonDir).lock)).resolves.toBe(true)
})
it('refuses a lock whose recorded owner is still running', async () => {
const commonDir = await gitCommonDir()
const { lock, marker } = paths(commonDir)
await writeFile(lock, 'in progress')
await writeFile(marker, JSON.stringify({ pid: process.pid }))
await expect(
new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
).resolves.toMatchObject({ ok: false })
await expect(exists(lock)).resolves.toBe(true)
})
it('reclaims the lock its own dead process left behind', async () => {
// SIGKILL and power loss bypass git's cleanup, and git never clears this itself.
const commonDir = await gitCommonDir()
const { lock, marker } = paths(commonDir)
await writeFile(lock, 'abandoned mid-rewrite')
await writeFile(marker, JSON.stringify({ pid: DEAD_PID }))
const claimed = await new PackRefsLockOwnership(commonDir).claim(
laterBy(ABANDONED_LOCK_AGE_MS + 1)
)
expect(claimed).toEqual({ ok: true })
await expect(exists(lock)).resolves.toBe(false)
await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid))
})
it('leaves a young lock alone even when the marker names a dead process', async () => {
// A marker outlives its lock, so a foreign lock can appear after our death.
// Age is the only thing separating our wreckage from somebody's live lock.
const commonDir = await gitCommonDir()
const { lock, marker } = paths(commonDir)
await writeFile(marker, JSON.stringify({ pid: DEAD_PID }))
await writeFile(lock, 'a different git process, started just now')
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false })
await expect(exists(lock)).resolves.toBe(true)
})
it('does not wedge a repository forever when the recorded pid was recycled', async () => {
const commonDir = await gitCommonDir()
const { lock, marker } = paths(commonDir)
await writeFile(lock, 'abandoned mid-rewrite')
// Our own pid stands in for a recycled one: alive, but not the process that wrote this.
await writeFile(marker, JSON.stringify({ pid: process.pid }))
await expect(
new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
).resolves.toMatchObject({ ok: false })
await expect(
new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1))
).resolves.toEqual({ ok: true })
await expect(exists(lock)).resolves.toBe(false)
})
it('refuses a lock whose marker is unreadable rather than guessing', async () => {
const commonDir = await gitCommonDir()
const { lock, marker } = paths(commonDir)
await writeFile(lock, 'in progress')
await writeFile(marker, 'not json')
await expect(
new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1))
).resolves.toMatchObject({ ok: false })
await expect(exists(lock)).resolves.toBe(true)
})
it('claims cleanly when a marker outlived its lock', async () => {
const commonDir = await gitCommonDir()
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true })
})
})
describe('stranded per-ref locks', () => {
it('clears the empty refs/**/*.lock files its own dead process left behind', async () => {
// `tempfile.c` opens the lock O_EXCL before linking it into the list the
// signal handler walks, so a kill in that window leaves a 0-byte file that
// Git never clears -- and `update-ref -d` on that ref then fails forever.
const commonDir = await gitCommonDir()
const namespace = join(commonDir, 'refs', 'remotes', 'origin')
await mkdir(namespace, { recursive: true })
await writeFile(join(namespace, 'main.lock'), '')
await writeFile(join(namespace, 'main'), 'a'.repeat(40))
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false)
// The ref itself is untouched.
await expect(exists(join(namespace, 'main'))).resolves.toBe(true)
})
it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => {
const commonDir = await gitCommonDir()
const namespace = join(commonDir, 'refs', 'heads')
await mkdir(namespace, { recursive: true })
await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40))
await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID }))
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true)
})
it('leaves ref locks alone when there is no marker naming a dead process', async () => {
const commonDir = await gitCommonDir()
const namespace = join(commonDir, 'refs', 'heads')
await mkdir(namespace, { recursive: true })
await writeFile(join(namespace, 'other.lock'), '')
await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1))
await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true)
})
})
+202
View File
@@ -0,0 +1,202 @@
import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import {
PACK_REFS_TIMEOUT_MS,
PACKED_REFS_LOCK_POLL_MS
} from '../../shared/repo-ref-maintenance-policy'
/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */
const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS
/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */
const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000
/** The ref tree is wide but shallow; this only stops a pathological walk. */
const REF_LOCK_SCAN_CEILING = 4096
/**
* Makes a `packed-refs.lock` Orca left behind attributable, and only that one.
*
* Git registers signal handlers that clean the lock up, but SIGKILL and power
* loss bypass them, and Git never removes a stale `packed-refs.lock` on its own
* -- every later ref deletion in that repository fails until someone deletes a
* file they have never heard of. Recording our pid beside the lock lets a later
* run recognise its own wreckage.
*
* Three independent conditions must all hold before anything is unlinked,
* because deleting a lock somebody else is holding is far worse than declining
* to pack: a marker must exist at all, the lock must be older than any
* `pack-refs` could legitimately run for, and the recorded process must be gone.
* A marker can outlive its lock, so age is what separates "our wreckage" from a
* foreign lock that happened to appear afterwards.
*/
export class PackRefsLockOwnership {
private readonly lockPath: string
private readonly markerPath: string
constructor(gitCommonDir: string) {
const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix
this.lockPath = path.join(gitCommonDir, 'packed-refs.lock')
this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner')
}
/** Refused when the lock belongs to something we cannot prove is our own wreckage. */
async claim(now = Date.now()): Promise<PackRefsLockClaim> {
const reclaim = await this.reclaimAbandonedLock(now)
if (!reclaim.ok) {
return reclaim
}
// Per-ref strands outlive their pack and are invisible to Git, which never
// clears a `refs/**\/*.lock` it did not create in this process.
await this.reclaimStrandedRefLocks(now)
try {
await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8')
} catch {
// Losing the marker only costs attribution on the next run, never correctness.
}
return { ok: true }
}
/**
* Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite
* window opens and closes. Cheap: one `stat` on a fixed path.
*/
watchLock(report: (held: boolean) => void): { stop: () => void } {
let stopped = false
let last = false
const tick = async (): Promise<void> => {
if (stopped) {
return
}
const held = (await fileAgeMs(this.lockPath, Date.now())) !== null
if (!stopped && held !== last) {
last = held
report(held)
}
}
const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS)
timer.unref?.()
void tick()
return {
stop: () => {
stopped = true
clearInterval(timer)
}
}
}
async release(): Promise<void> {
await rm(this.markerPath, { force: true }).catch(() => {})
}
private async reclaimAbandonedLock(now: number): Promise<PackRefsLockClaim> {
const lockAgeMs = await fileAgeMs(this.lockPath, now)
if (lockAgeMs === null) {
return { ok: true }
}
// No marker means the lock is not ours to reason about, let alone remove.
const marker = await readOwnerMarker(this.markerPath)
if (marker === null) {
return { ok: false, reason: 'held by another process' }
}
if (lockAgeMs < ABANDONED_LOCK_AGE_MS) {
// Ours, but too young to be certain the writer is gone. Worth retrying soon.
return { ok: false, reason: 'our own lock, not yet old enough to reclaim' }
}
// Past the pid-reuse horizon the pid proves nothing, and a lock this old is
// abandoned whoever wrote it -- otherwise a recycled pid would wedge the
// repository permanently.
if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) {
return { ok: false, reason: 'the recorded owner is still running' }
}
await rm(this.lockPath, { force: true }).catch(() => {})
await rm(this.markerPath, { force: true }).catch(() => {})
return { ok: true }
}
/**
* Clear `refs/**\/*.lock` files a dead pack of ours left behind.
*
* `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it
* into the list the signal handler walks, so a kill inside that window leaves
* a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref
* fail with `cannot lock ref ... File exists`, forever. Same three conditions
* as the packed-refs lock, plus a size check: a live writer's lock is not empty.
*/
private async reclaimStrandedRefLocks(now: number): Promise<void> {
const marker = await readOwnerMarker(this.markerPath)
if (marker === null || isProcessAlive(marker.pid)) {
return
}
const markerAgeMs = await fileAgeMs(this.markerPath, now)
if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) {
return
}
const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix
const pending = [path.join(path.dirname(this.markerPath), 'refs')]
let visited = 0
while (pending.length > 0) {
const directory = pending.pop()
if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) {
return
}
let entries: { name: string; isDirectory: () => boolean }[]
try {
entries = await readdir(directory, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(directory, entry.name)
if (entry.isDirectory()) {
pending.push(full)
} else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) {
await rm(full, { force: true }).catch(() => {})
}
}
}
}
}
export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string }
/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */
async function isEmptyFile(path: string): Promise<boolean> {
try {
return (await stat(path)).size === 0
} catch {
return false
}
}
async function readOwnerMarker(path: string): Promise<{ pid: number } | null> {
try {
const raw = (await readFile(path, 'utf-8')).slice(0, 256)
const pid = (JSON.parse(raw) as { pid?: unknown }).pid
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null
} catch {
return null
}
}
/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */
async function fileAgeMs(path: string, now: number): Promise<number | null> {
try {
return Math.max(0, now - (await stat(path)).mtimeMs)
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0
}
}
function isProcessAlive(pid: number): boolean {
if (pid === process.pid) {
return true
}
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
+36 -20
View File
@@ -7,6 +7,10 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import {
postponeRepoRefMaintenance,
withRepoRefMaintenancePaused
} from './local-repo-ref-maintenance'
import { validateGitPushTarget } from './push-target-validation'
import { gitExecFileAsync } from './runner'
import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec'
@@ -260,8 +264,11 @@ export async function gitPull(
// Why: plain `git pull` uses the user's configured pull strategy (merge by
// default) so diverged branches reconcile instead of erroring out. Conflicts
// surface through the existing conflict-resolution flow.
await runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options))
postponeRepoRefMaintenance()
await withRepoRefMaintenancePaused('git-pull', () =>
runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options))
)
)
}
@@ -270,9 +277,12 @@ export async function gitFastForward(
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
await runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() =>
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
postponeRepoRefMaintenance()
await withRepoRefMaintenancePaused('git-fast-forward', () =>
runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() =>
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
)
)
)
}
@@ -282,22 +292,28 @@ export async function gitFetch(
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
// `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a
// running idle pack holds while it rewrites -- ~1.4s at most. This is the user
// clicking Fetch, so wait that window out rather than letting it fail on the lock.
postponeRepoRefMaintenance()
try {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
const runtimeOptions = gitOptionsForWorktree(worktreePath, options)
await fetchForkRemoteWithStaleRefspecRepair(
(args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }),
worktreePath,
target.remoteName,
() =>
gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then(
() => undefined
)
)
return
}
await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options))
await withRepoRefMaintenancePaused('git-fetch', async () => {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
const runtimeOptions = gitOptionsForWorktree(worktreePath, options)
await fetchForkRemoteWithStaleRefspecRepair(
(args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }),
worktreePath,
target.remoteName,
() =>
gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then(
() => undefined
)
)
return
}
await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options))
})
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
}
@@ -0,0 +1,49 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchConflictKind } from './repo-branch-conflict'
describe('branch conflict real Git contract', () => {
const tempPaths: string[] = []
afterEach(() => {
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('decides remote conflicts from one batched probe across many remotes', async () => {
const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-'))
tempPaths.push(repoPath)
const git = (...args: string[]): string =>
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
git('init', '--quiet')
git('config', 'user.name', 'Orca Test')
git('config', 'user.email', 'orca@example.test')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
git('add', 'fixture.txt')
git('commit', '--quiet', '-m', 'base')
const head = git('rev-parse', 'HEAD').trim()
// Many remotes is the shape that used to cost one subprocess each.
for (let index = 0; index < 12; index += 1) {
git('remote', 'add', `remote${index}`, 'https://example.test/repo.git')
}
git('update-ref', 'refs/remotes/remote7/taken', head)
await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote')
await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull()
// The allowed base ref is the one remote spelling that is not a conflict.
await expect(
getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken')
).resolves.toBeNull()
git('branch', 'local-only', head)
await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local')
})
})
+120
View File
@@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => {
expect(exec).not.toHaveBeenCalled()
})
})
describe('getBranchConflictKindViaExec batched remote probe', () => {
function remoteNames(count: number): string {
return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n`
}
function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> {
return async (argv) => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
}
it('asks one batched child instead of one probe per remote', async () => {
const calls: string[][] = []
const stdinPayloads: (string | undefined)[] = []
const exec = baseExec(calls)
const batched = async (
argv: string[],
options: { stdin: string }
): Promise<{ stdout: string }> => {
calls.push(argv)
stdinPayloads.push(options.stdin)
return {
stdout: [
'refs/remotes/remote0/feature missing',
'refs/remotes/remote1/feature missing',
'refs/remotes/remote2/feature missing'
].join('\n')
}
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls).toEqual([
['rev-parse', '--verify', 'refs/heads/feature'],
['remote'],
['cat-file', '--batch-check']
])
expect(stdinPayloads).toEqual([
'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n'
])
})
it('reports a remote conflict from the batched answer', async () => {
const calls: string[][] = []
const exec = baseExec(calls)
const batched = async (): Promise<{ stdout: string }> => ({
stdout: [
'refs/remotes/remote0/feature missing',
`${'a'.repeat(40)} commit 214`,
'refs/remotes/remote2/feature missing'
].join('\n')
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
})
it('falls back to per-ref probes when the batch cannot answer', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
if (argv[4] === 'refs/remotes/remote1/feature') {
return { stdout: 'abc refs/remotes/remote1/feature\n' }
}
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => {
throw new Error('cat-file is unavailable')
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
it('treats a short batch read as undecided rather than as absence', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => ({
stdout: 'refs/remotes/remote0/feature missing'
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
})
+43 -10
View File
@@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import {
probeAnyExactRef,
probeAnyExactRefBatched,
type ExactRefProbeExec,
type ExactRefProbeExecOptions
type ExactRefProbeExecOptions,
type ExactRefProbeStdinExec
} from './exact-ref-probe'
export type BranchConflictKind = 'local' | 'remote'
@@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs(
return [...refs]
}
/** One batched child answers for every remote; the per-ref probes only run when the host cannot
* feed stdin, or when the batch came back undecided. */
async function probeAnyRemoteConflictRef(
exec: ExactRefProbeExec,
batchedExec: ExactRefProbeStdinExec | undefined,
candidateRefs: readonly string[],
probeOptions: ExactRefProbeExecOptions
): Promise<{ found: boolean }> {
if (batchedExec) {
// A present ref is always decisive, so `found` never survives with `unknown` set.
const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions)
if (!batched.unknown) {
return { found: batched.found }
}
}
return probeAnyExactRef(exec, candidateRefs, probeOptions)
}
/** Run branch-conflict policy through the host that owns Git execution. */
export async function getBranchConflictKindViaExec(
exec: ExactRefProbeExec,
branchName: string,
allowedBaseRef?: string,
options: ExactRefProbeExecOptions = {}
options: ExactRefProbeExecOptions = {},
batchedExec?: ExactRefProbeStdinExec
): Promise<BranchConflictKind | null> {
if (!canQueryRemoteBranchName(branchName)) {
return null
@@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec(
return null
}
const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions)
const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef(
exec,
batchedExec,
candidateRefs,
probeOptions
)
return hasRemoteConflict ? 'remote' : null
} catch {
@@ -119,15 +145,22 @@ export function getBranchConflictKind(
options: LocalGitExecOptions = {}
): Promise<BranchConflictKind | null> {
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
argv: string[],
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
): Promise<{ stdout: string }> =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
})
return getBranchConflictKindViaExec(
(argv, commandOptions) =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs })
}),
runLocalGit,
branchName,
allowedBaseRef
allowedBaseRef,
{},
(argv, commandOptions) => runLocalGit(argv, commandOptions)
)
}
@@ -0,0 +1,290 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { countLooseRefs } from '../../shared/loose-ref-count'
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
import {
_resetLocalRepoRefMaintenanceForTests,
createLocalRepoRefMaintenanceTarget,
getLocalRepoRefMaintenance,
setRepoMaintenanceActivityProbe
} from './local-repo-ref-maintenance'
import { forceDeleteLocalBranch } from './worktree-branch-removal'
const roots: string[] = []
// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts
// three real `pack-refs` runs before the deferral budget is spent.
const QUIET_MS = 25
const THRESHOLD = 20
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
}
/** A repo whose only loose-ref backlog is the one the test asks for. */
async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-'))
roots.push(root)
const repoPath = join(root, 'repo')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await writeFile(join(repoPath, 'file.txt'), 'one\n')
git(repoPath, ['add', 'file.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
const head = git(repoPath, ['rev-parse', 'HEAD'])
// Written directly: `update-ref` for thousands of refs is the slow part of the fixture.
const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin')
await mkdir(namespace, { recursive: true })
for (let index = 0; index < looseRefs; index += 1) {
await writeFile(join(namespace, `branch-${index}`), `${head}\n`)
}
return { repoPath, refsDir: join(repoPath, '.git', 'refs') }
}
function createMaintenance(onPackRefs: () => void = () => {}): {
maintenance: RepoRefMaintenance
arm: (repoPath: string) => void
} {
const maintenance = new RepoRefMaintenance({
quietPeriodMs: QUIET_MS,
looseRefThreshold: THRESHOLD
})
return {
maintenance,
arm: (repoPath: string) => {
const target = createLocalRepoRefMaintenanceTarget({
key: `local::${repoPath}`,
repoPath
})
maintenance.arm({
...target,
packRefs: async (signal) => {
onPackRefs()
await target.packRefs(signal)
}
})
}
}
}
async function settle(maintenance: RepoRefMaintenance): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4))
await maintenance.whenAttemptSettled()
}
/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */
async function settleUntil(
maintenance: RepoRefMaintenance,
done: () => Promise<boolean>
): Promise<void> {
for (let round = 0; round < 100; round += 1) {
if (await done()) {
return
}
await settle(maintenance)
}
}
afterEach(async () => {
_resetLocalRepoRefMaintenanceForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('idle ref maintenance against real Git', () => {
it('packs a backlogged repository down to zero loose refs', async () => {
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
const { maintenance, arm } = createMaintenance()
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({
count: THRESHOLD + 31
})
arm(repoPath)
await settle(maintenance)
maintenance.dispose()
await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false })
// The refs survived the move into packed-refs; nothing was lost.
expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength(
THRESHOLD + 31
)
expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch(
/^[0-9a-f]{40}$/
)
}, 30_000)
it('leaves a healthy repository untouched', async () => {
const { repoPath, refsDir } = await createRepo(2)
let packed = 0
const { maintenance, arm } = createMaintenance(() => {
packed += 1
})
arm(repoPath)
await settle(maintenance)
maintenance.dispose()
expect(packed).toBe(0)
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 })
}, 30_000)
it('honours maintenance.auto=false in the repository config', async () => {
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
git(repoPath, ['config', 'maintenance.auto', 'false'])
let packed = 0
const { maintenance, arm } = createMaintenance(() => {
packed += 1
})
arm(repoPath)
await settle(maintenance)
maintenance.dispose()
expect(packed).toBe(0)
await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({
count: THRESHOLD + 31
})
}, 30_000)
it('runs one repository at a time even when several go quiet together', async () => {
const repos = await Promise.all([
createRepo(THRESHOLD + 5),
createRepo(THRESHOLD + 5),
createRepo(THRESHOLD + 5)
])
let concurrent = 0
let peak = 0
const maintenance = new RepoRefMaintenance({
quietPeriodMs: QUIET_MS,
looseRefThreshold: THRESHOLD
})
for (const { repoPath } of repos) {
const target = createLocalRepoRefMaintenanceTarget({
key: `local::${repoPath}`,
repoPath
})
maintenance.arm({
...target,
packRefs: async (signal) => {
concurrent += 1
peak = Math.max(peak, concurrent)
try {
await target.packRefs(signal)
} finally {
concurrent -= 1
}
}
})
}
const allPacked = async (): Promise<boolean> => {
const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000)))
return counts.every((scan) => scan.count === 0)
}
await settleUntil(maintenance, allPacked)
maintenance.dispose()
expect(peak).toBe(1)
for (const { refsDir } of repos) {
await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({
count: 0,
saturated: false
})
}
}, 60_000)
})
describe('yielding the repository to work that deletes refs', () => {
it('waits for the packed-refs lock and succeeds while the prune continues', async () => {
// The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s
// run; the rest is the prune, during which a concurrent `update-ref -d`
// succeeds on its own because per-ref locks last microseconds. Signalling
// the child there strands a `refs/**` lock Git never clears.
const { repoPath } = await createRepo(0)
git(repoPath, ['branch', 'doomed'])
const head = git(repoPath, ['rev-parse', 'refs/heads/doomed'])
let packing = false
let releaseLock: (() => void) | undefined
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 })
setRepoMaintenanceActivityProbe(() => false)
getLocalRepoRefMaintenance().arm({
key: `local::${repoPath}`,
resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'),
packRefs: async (lock) => {
packing = true
lock.setHeld(true)
// Stands in for the rewrite window, then the long prune that follows it.
await new Promise<void>((resolve) => {
releaseLock = () => {
lock.setHeld(false)
resolve()
}
})
}
})
for (let attempt = 0; attempt < 200 && !packing; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, QUIET_MS))
}
expect(packing).toBe(true)
// The real deletion path, which routes through withRepoRefMaintenancePaused.
let deleted = false
const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => {
deleted = true
})
// It must still be waiting: the rewrite window is open.
await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4))
expect(deleted).toBe(false)
expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed')
// Releasing the window is enough -- the pack is never cancelled.
releaseLock?.()
await deletion
expect(deleted).toBe(true)
expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('')
}, 30_000)
it('does not block the caller once the rewrite window has closed', async () => {
// The prune phase is concurrency-safe, so a caller arriving during it pays
// nothing at all.
const { repoPath } = await createRepo(0)
git(repoPath, ['branch', 'doomed'])
const head = git(repoPath, ['rev-parse', 'refs/heads/doomed'])
let pruning = false
let finishPrune: (() => void) | undefined
_resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 })
setRepoMaintenanceActivityProbe(() => false)
getLocalRepoRefMaintenance().arm({
key: `local::${repoPath}`,
resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'),
packRefs: async (lock) => {
lock.setHeld(true)
lock.setHeld(false)
pruning = true
await new Promise<void>((resolve) => {
finishPrune = resolve
})
}
})
for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, QUIET_MS))
}
const startedAt = Date.now()
await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined()
expect(Date.now() - startedAt).toBeLessThan(2_000)
finishPrune?.()
}, 30_000)
})
@@ -0,0 +1,59 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { GitPushTarget } from '../../shared/worktree/types'
import { getUpstreamStatus } from './upstream'
// Why: on-demand remote materialization (#17828) defers `git remote add` for a
// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's
// status must be read against a pushTarget whose remote was never created.
// This exercises the real `rev-parse --verify --quiet` failure path -- a
// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is
// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on.
describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => {
const tempPaths: string[] = []
afterEach(() => {
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => {
const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-'))
tempPaths.push(repoPath)
const git = (...args: string[]): string =>
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
git('init', '--quiet')
git('config', 'user.name', 'Orca Test')
git('config', 'user.email', 'orca@example.test')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
git('add', 'fixture.txt')
git('commit', '-m', 'base')
git('branch', '-M', 'contributor/fix')
// Simulates a fork-PR review worktree right after create: pushTarget
// metadata is persisted, but `pr-contributor-orca` was never added as a
// remote because materialization is deferred to first use.
const pushTarget: GitPushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'git@github.com:contributor/orca.git'
}
const status = await getUpstreamStatus(repoPath, pushTarget)
expect(status).toEqual({
hasUpstream: false,
upstreamName: 'pr-contributor-orca/contributor/fix',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: true
})
})
})
+12 -9
View File
@@ -4,6 +4,7 @@ import type {
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
@@ -149,15 +150,17 @@ export async function addWorktree(
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
try {
return await runWithGitReadCacheInvalidation(() =>
performAddWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
noCheckout,
options
return await withRepoRefMaintenancePaused('worktree-add', () =>
runWithGitReadCacheInvalidation(() =>
performAddWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
noCheckout,
options
)
)
)
} finally {
@@ -0,0 +1,99 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
measureRetargetDivergence,
RETARGET_MAX_COMMIT_DIVERGENCE
} from './worktree-base-divergence'
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
}
async function createRepo(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-'))
tempRoots.push(root)
const repoPath = join(root, 'repo')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await writeFile(join(repoPath, 'version.txt'), 'one\n')
git(repoPath, ['add', 'version.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
return repoPath
}
function commitEmpty(repoPath: string, count: number): void {
for (let index = 0; index < count; index += 1) {
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
}
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('measureRetargetDivergence with real Git', () => {
it('allows the drift between a local branch and its remote-tracking copy', async () => {
const repoPath = await createRepo()
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
commitEmpty(repoPath, 5)
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3'])
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('within')
})
it('counts drift in both directions', async () => {
const repoPath = await createRepo()
const forkPoint = git(repoPath, ['rev-parse', 'HEAD'])
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE)
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['reset', '--hard', '--quiet', forkPoint])
commitEmpty(repoPath, 1)
// 100 ahead + 1 behind is over the cap even though neither side alone exceeds it.
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
})
it('refuses a base that has drifted past the cap', async () => {
const repoPath = await createRepo()
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1)
await expect(
measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main')
).resolves.toBe('exceeded')
})
it('refuses unrelated histories, which share no commits at all', async () => {
const repoPath = await createRepo()
git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated'])
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root'])
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated')
).resolves.toBe('exceeded')
})
it('reports an unreadable ref as unverifiable, not as excess drift', async () => {
const repoPath = await createRepo()
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing')
).resolves.toBe('unknown')
})
})
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync }))
import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout'
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
import {
measureRetargetDivergence,
RETARGET_DIVERGENCE_BUDGET_MS
} from './worktree-base-divergence'
type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal }
function callOptions(): ExecOptions[] {
return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions)
}
function subcommands(): string[] {
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
}
function answerProbes(count: string, mergeBase = 'abc123\n') {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) =>
args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count }
)
}
function exitCodeError(code: number): Error & { code: number } {
return Object.assign(new Error('git exited'), { code })
}
beforeEach(() => {
mocks.gitExecFileAsync.mockReset()
})
describe('measureRetargetDivergence deadlines', () => {
it('puts every probe under one shared budget, not a budget each', async () => {
answerProbes('3\n')
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('within')
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
const signals = callOptions().map((options) => options.signal)
// One signal object across all three: the counts and merge-base are staged, so per-probe
// budgets would let the check cost the sum of them.
expect(new Set(signals).size).toBe(1)
expect(signals[0]).toBeInstanceOf(AbortSignal)
})
it('also gives each probe a command timeout well below git default read deadline', async () => {
answerProbes('3\n')
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
// The signal covers admission queueing and the WSL environment wait, which start before a
// command timeout exists; the timeout still covers a hung spawn.
for (const options of callOptions()) {
expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS)
}
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
})
it('really aborts the in-flight probes when the shared budget expires', async () => {
// A probe that behaves like a slow walk: it produces nothing on its own and only settles when
// its signal fires. If the budget never fired, or never reached the probe, this hangs and the
// test fails on its own timeout rather than passing on a signal that does nothing.
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
budgetMsForTest: 25
})
).resolves.toBe('unknown')
})
it('clears the WSL read-environment wait, which starts before any command timeout', () => {
// Equal to it would make the first WSL-routed create of a session `unknown` by construction,
// and the WSL numbers meaningless.
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS)
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
})
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
const controller = new AbortController()
const pending = measureRetargetDivergence(
'/repo',
'refs/heads/main',
'refs/remotes/origin/main',
// A budget long enough that only the caller's cancellation can end this in time.
{ signal: controller.signal, budgetMsForTest: 60_000 }
)
controller.abort()
await expect(pending).resolves.toBe('unknown')
})
it('reports a blown deadline as unverifiable rather than as excess drift', async () => {
mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.'))
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
// A count that never answered must not go on to spend a merge-base walk.
expect(subcommands()).not.toContain('merge-base')
})
it('separates merge-base saying no from merge-base failing', async () => {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
// Exit 1 is Git's answer for unrelated histories.
throw exitCodeError(1)
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
mocks.gitExecFileAsync.mockReset()
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
// A timeout carries no exit code and must not be read as "no common ancestor".
throw new Error('git timed out.')
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
})
it('reports drift past the cap without spending a merge-base walk', async () => {
answerProbes('101\n')
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
expect(subcommands()).not.toContain('merge-base')
})
it('routes every probe to the caller-named WSL distro', async () => {
answerProbes('1\n')
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
wslDistro: 'Ubuntu'
})
for (const options of callOptions()) {
expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' })
}
})
})
+165
View File
@@ -0,0 +1,165 @@
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
import { gitExecFileAsync } from './runner'
export type RetargetDivergenceOptions = {
wslDistro?: string
/** The create's own cancellation signal. Without it a cancelled create leaves these probes
* running until the budget expires. */
signal?: AbortSignal
/** Shortens only the end-to-end budget so a test can observe a real abort; production always
* uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */
budgetMsForTest?: number
}
/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could
* not be evaluated" have different causes and different fixes, and only the second one means a
* retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a
* cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */
export type RetargetDivergence = 'within' | 'exceeded' | 'unknown'
/**
* How far two bases may drift and still be worth retargeting a prepared checkout between.
*
* Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74
* files apart, while an abandoned fork's `main` — same branch name, so the same base family —
* was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit
* count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff
* the retarget reset would have to write.
*/
export const RETARGET_MAX_COMMIT_DIVERGENCE = 100
/**
* Headroom for the walk itself, on top of the worst pre-spawn wait.
*
* ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a
* cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by
* construction.
*/
const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500
/**
* End-to-end deadline for the whole check, not per probe.
*
* Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe
* before a command timeout even exists, so a budget merely equal to it would guarantee `unknown`
* on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it
* keeps that relationship explicit instead of coincidental.
*
* Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold
* `worktree add`, so when it expires the create pays the budget and then does that add anyway. The
* total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`.
*
* It must be a signal, not just a per-command timeout, because a per-command timeout starts only
* after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the
* counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number
* written here.
*/
export const RETARGET_DIVERGENCE_BUDGET_MS =
WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS
function probeOptions(
repoPath: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } {
// Built field by field rather than spread: the caller's bag carries a test-only key that must
// never reach git's exec options.
// Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a
// command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn.
return {
cwd: repoPath,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
signal,
timeout: RETARGET_DIVERGENCE_BUDGET_MS
}
}
/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */
async function countCommitsAhead(
repoPath: string,
fromRef: string,
toRef: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): Promise<number | null> {
try {
// `--max-count` stops the walk, so an unrelated history costs a bounded number of commits
// rather than a full traversal. Both flags predate the Git 2.25 baseline.
// `--end-of-options` (Git 2.24) because a range whose left side began with `-` would
// otherwise parse as an option; callers only pass `refs/`-qualified names today, and this
// keeps that from being load-bearing.
const { stdout } = await gitExecFileAsync(
[
'rev-list',
'--count',
`--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`,
'--end-of-options',
`${fromRef}..${toRef}`
],
probeOptions(repoPath, options, signal)
)
const count = Number.parseInt(stdout.trim(), 10)
return Number.isNaN(count) ? null : count
} catch {
return null
}
}
/** True/false when Git decided, null when the probe was unusable. */
async function hasCommonHistory(
repoPath: string,
leftRef: string,
rightRef: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): Promise<boolean | null> {
try {
const { stdout } = await gitExecFileAsync(
['merge-base', '--end-of-options', leftRef, rightRef],
probeOptions(repoPath, options, signal)
)
return stdout.trim().length > 0
} catch (error) {
// Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort
// carries no exit code and must not be read as one.
return (error as { code?: unknown }).code === 1 ? false : null
}
}
/**
* Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap.
*
* Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes
* reusing the checkout, so every other outcome lands on the cold create path.
*/
export async function measureRetargetDivergence(
repoPath: string,
preparedBase: string,
targetBase: string,
options: RetargetDivergenceOptions = {}
): Promise<RetargetDivergence> {
const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS)
// Combined so cancelling the create stops the probes immediately rather than at the deadline.
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget
// Both directions: commits the target adds decide what the reset writes, commits only the
// preparation has decide what it must delete.
const [ahead, behind] = await Promise.all([
countCommitsAhead(repoPath, preparedBase, targetBase, options, signal),
countCommitsAhead(repoPath, targetBase, preparedBase, options, signal)
])
if (ahead === null || behind === null) {
return 'unknown'
}
if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) {
return 'exceeded'
}
// Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of
// them in full. Reaching here already proved neither side is more than the cap ahead of the
// other, which bounds that walk — and unrelated histories of any size fail the counts first.
// Required because unrelated histories replace the whole tree however few commits they carry.
const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal)
if (shareHistory === null) {
return 'unknown'
}
return shareHistory ? 'within' : 'exceeded'
}
+15
View File
@@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef(
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
/**
* The qualified ref a worktree base names in this repo, or the base unchanged when nothing
* matches. Callers that key on a base must compare this, not the raw string, or `main` and
* `refs/heads/main` look like different bases.
*/
export function resolveLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
options: GitExecOptions = {}
): Promise<string> {
return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
}
/**
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
* full commit id — already resolves in this repo's own object/ref store.
+6 -1
View File
@@ -4,6 +4,7 @@ import {
} from '../../shared/git-branch-cleanup'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { gitExecFileAsync } from './runner'
import { parseWorktreeList } from './worktree-list-parser'
import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options'
@@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch(
}
// Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead.
try {
await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
// `update-ref -d` needs the packed-refs lock a running idle pack holds while
// it rewrites; waits it out rather than cancelling the pack.
await withRepoRefMaintenancePaused('branch-delete', () =>
runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
)
} catch {
throw new Error(
`Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.`
@@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => {
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
})
it('lands a cross-base retarget on exactly the requested commit', async () => {
const { repoPath, root } = await createRepo()
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
const preparedPath = join(preparationRoot, `${process.pid}-retarget`)
const finalPath = join(root, 'retargeted-worktree')
await mkdir(preparationRoot, { recursive: true })
await writeFile(join(repoPath, 'shared.txt'), 'kept\n')
git(repoPath, ['add', 'shared.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'local main'])
const localMainHead = git(repoPath, ['rev-parse', 'HEAD'])
// A remote-tracking `main` that diverged: different content, an extra file, and one deletion.
git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main'])
await writeFile(join(repoPath, 'version.txt'), 'two\n')
await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n')
git(repoPath, ['rm', '--quiet', 'shared.txt'])
git(repoPath, ['add', 'version.txt', 'only-upstream.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'upstream main'])
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['checkout', '--quiet', 'main'])
git(repoPath, ['branch', '--quiet', '-D', 'upstream-main'])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'refs/remotes/origin/main',
createWorktreePreparationLockReason('retarget-test')
)
expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead)
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main')
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead)
// A retarget that left stale files behind would be a wrong checkout, not just a slow one.
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
'one\n'
)
expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
'kept\n'
)
await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow()
expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted')
expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe(
'refs/heads/main'
)
})
it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => {
const { repoPath, root } = await createRepo()
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
+42 -39
View File
@@ -10,6 +10,7 @@ import {
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
} from './worktree'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
@@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout(
options: GitWorktreeExecOptions = {}
): Promise<void> {
try {
await runWithGitReadCacheInvalidation(async () => {
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
try {
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'add',
'--detach',
'--no-checkout',
worktreePath,
effectiveBase
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
await withRepoRefMaintenancePaused('worktree-prepare', () =>
runWithGitReadCacheInvalidation(async () => {
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
// Why: reset materializes files without running user post-checkout hooks before submit.
await gitExecFileAsync(
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'lock',
'--reason',
lockReason,
worktreePath
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
} catch (error) {
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
throw error
}
})
try {
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'add',
'--detach',
'--no-checkout',
worktreePath,
effectiveBase
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
// Why: reset materializes files without running user post-checkout hooks before submit.
await gitExecFileAsync(
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'lock',
'--reason',
lockReason,
worktreePath
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
} catch (error) {
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
throw error
}
})
)
} finally {
notifyPreparedWorktreeMutation(repoPath)
}
+1 -1
View File
@@ -97,7 +97,7 @@ export async function listWorktreesStrict(
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
}
async function annotateSparseCheckoutStatus(
export async function annotateSparseCheckoutStatus(
repoPath: string,
worktrees: GitWorktreeInfo[],
options: GitWorktreeExecOptions = {}
+8 -2
View File
@@ -22,6 +22,7 @@ import {
} from './worktree-operation-options'
import { areWorktreePathsEqual } from './worktree-path-comparison'
import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache'
import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache'
@@ -36,8 +37,13 @@ export async function removeWorktree(
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
return await runWithGitReadCacheInvalidation(() =>
performRemoveWorktree(repoPath, worktreePath, force, options)
// Removal deletes branches, and a ref deletion needs the packed-refs lock a
// running idle pack holds while it rewrites. Waits that window out; the
// prune phase that follows it is concurrency-safe and is left to finish.
return await withRepoRefMaintenancePaused('worktree-remove', () =>
runWithGitReadCacheInvalidation(() =>
performRemoveWorktree(repoPath, worktreePath, force, options)
)
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
@@ -0,0 +1,93 @@
// The annotated listing is the graph listing plus a sparse probe: callers that read only
// `worktree.path` must skip the probe, without costing a second `git worktree list`.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } =
vi.hoisted(() => ({
detectSparseCheckoutMock: vi.fn(),
readWorktreeListMock: vi.fn(),
readTranslatedWorktreeGraphMock: vi.fn()
}))
vi.mock('./worktree-sparse-state', () => ({
detectSparseCheckout: detectSparseCheckoutMock,
resolveGitCommonDir: vi.fn()
}))
vi.mock('./worktree-list-reader', () => ({
readCheckedOutBranchRef: vi.fn(),
readRepoCommonDirFromGit: vi.fn(),
readRepoLocation: vi.fn(),
readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock,
readWorktreeHeadOid: vi.fn(),
readWorktreeList: readWorktreeListMock
}))
import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree'
import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache'
const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
const ROW: GitWorktreeInfo = {
path: 'C:\\wt\\x',
head: 'a'.repeat(40),
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
describe('graph and annotated worktree scans', () => {
beforeEach(() => {
detectSparseCheckoutMock.mockReset()
detectSparseCheckoutMock.mockResolvedValue(true)
readWorktreeListMock.mockReset()
readWorktreeListMock.mockResolvedValue([ROW])
readTranslatedWorktreeGraphMock.mockReset()
readTranslatedWorktreeGraphMock.mockResolvedValue([ROW])
_resetWorktreeScanCacheForTests()
__resetSparseCheckoutStateCacheForTests()
})
it('does not probe sparse state for a graph scan', async () => {
const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
expect(rows[0]?.path).toBe('C:\\wt\\x')
expect(rows[0]?.isSparse).toBeUndefined()
expect(detectSparseCheckoutMock).not.toHaveBeenCalled()
})
it('still probes sparse state for the annotated scan', async () => {
const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' })
expect(rows[0]?.isSparse).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('reads the git listing once for an overlapping graph and annotated scan', async () => {
const [graphRows, annotatedRows] = await Promise.all([
listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }),
listWorktrees(REPO, { wslDistro: 'Ubuntu' })
])
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1)
expect(graphRows[0]?.isSparse).toBeUndefined()
expect(annotatedRows[0]?.isSparse).toBe(true)
})
// Sharing the listing must not make the probe-free caller wait on the probe it opted out of.
it('resolves a graph scan while the annotated scan is still probing', async () => {
let releaseProbe!: () => void
detectSparseCheckoutMock.mockImplementation(
() =>
new Promise((resolve) => {
releaseProbe = () => resolve(true)
})
)
const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' })
const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
expect(graphRows[0]?.path).toBe('C:\\wt\\x')
releaseProbe()
expect((await annotatedScan)[0]?.isSparse).toBe(true)
})
})
@@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => {
// The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree
// list` and only the annotated caller pays the probe. They ran Git twice before.
it('runs one git listing for concurrent graph and annotated scans', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
@@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => {
const graphScan = listWorktreeGraph('/repo')
const annotatedScan = listWorktrees('/repo')
expect(resolvers).toHaveLength(2)
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([graphScan, annotatedScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
// Order must not matter: whichever runs first owns the listing and the other joins it.
it('runs one git listing when the annotated scan starts first', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const annotatedScan = listWorktrees('/repo')
const graphScan = listWorktreeGraph('/repo')
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([annotatedScan, graphScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('keeps graph scans with an AbortSignal isolated from shared callers', async () => {
@@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => {
expect(scanResolvers).toHaveLength(1)
await moveWorktree('/repo', '/repo-old', '/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
// Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers.
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
const freshScan = listWorktrees('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 })
scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n')
expect((await freshScan)[0]?.path).toBe('/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n')
expect((await staleScan)[0]?.path).toBe('/repo')
@@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => {
const newestScan = listWorktrees('/repo')
expect(listCalls).toBe(3)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 })
scanResolvers[0]?.()
scanResolvers[2]?.()
+19 -3
View File
@@ -1,8 +1,8 @@
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
annotateSparseCheckoutStatus,
listWorktreeGraph as listWorktreeGraphUnshared,
listWorktreesStrict as listWorktreesStrictUnshared,
listWorktreesUnshared
listWorktreesStrict as listWorktreesStrictUnshared
} from './worktree-listing'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
@@ -90,6 +90,22 @@ function shareWorktreeScan(
return scan
}
/**
* Sparse annotation layered over the shared graph scan rather than its own `git worktree list`.
*
* Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the
* per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes
* without costing a second subprocess when it overlaps a badge reader — the two ran Git twice
* before. Strict stays on its own scan because it must be able to reject.
*/
async function runAnnotatedWorktreeScan(
repoPath: string,
options: GitWorktreeExecOptions
): Promise<GitWorktreeInfo[]> {
const worktrees = await listWorktreeGraph(repoPath, options)
return annotateSparseCheckoutStatus(repoPath, worktrees, options)
}
/**
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
@@ -99,7 +115,7 @@ export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared)
return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan)
}
/**
+50
View File
@@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots
import { initializeMainProcessReady } from './startup/main-process-ready'
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.desktopActivationGate?.requestActivation()
}
/**
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
*
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
* with no listener attached is dropped silently and the queue would be gone.
*/
function publishOsOpenedMarkdownFiles(): void {
const targetWindow = state.mainWindow
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
return
}
// Why consumed before the await: a renderer pull racing this resolve must not take the same
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
const filePaths = state.osOpenedMarkdownFiles.consume()
if (filePaths.length === 0) {
return
}
void resolveOpenedMarkdownDocuments(filePaths)
.then((documents) => {
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
state.osOpenedMarkdownFiles.restore(filePaths)
return
}
if (documents.length > 0) {
targetWindow.webContents.send('ui:openMarkdownFiles', documents)
}
})
.catch((error) => {
state.osOpenedMarkdownFiles.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
})
}
const handleMacAppActivation = createMacAppActivationHandler({
getWindow: () => state.mainWindow,
requestActivation: requestDesktopActivation
@@ -53,7 +88,22 @@ if (preflightReady) {
event.preventDefault()
requestDesktopActivation([url])
})
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
// that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
return
}
event.preventDefault()
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
// activating the gate here would try to open one before Electron can.
if (app.isReady()) {
requestDesktopActivation()
}
})
state.skillShareDeepLinks.capture(process.argv)
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
state.osOpenedMarkdownFiles.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
@@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import {
@@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => {
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktrees: vi.fn() }
return { ...actual, listRepoWorktreeGraph: vi.fn() }
})
const repo: Repo = {
@@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
vi.mocked(listRepoWorktreeGraph).mockReset()
// The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild
// reports success with the recovered row missing and the probe is the only remaining evidence.
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
statMock.mockReset()
statMock.mockResolvedValue({})
})
+13 -13
View File
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
@@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return {
...actual,
listRepoWorktrees: vi.fn()
listRepoWorktreeGraph: vi.fn()
}
})
@@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
vi.mocked(listRepoWorktreeGraph).mockReset()
})
it('rebuilds the authorized roots cache for large worktree lists', async () => {
@@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => {
isMainWorktree: false
})
)
vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees)
const store = makeStore()
await rebuildAuthorizedRootsCache(store)
@@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => {
await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe(
resolve(lastWorktreePath)
)
expect(listRepoWorktrees).toHaveBeenCalledTimes(1)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1)
})
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
@@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => {
// a worktree a create just recovered without a listing (#16520).
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.'))
vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.'))
await rebuildAuthorizedRootsCache(store)
@@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => {
await mkdir(recovered)
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, recovered)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
@@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => {
await mkdir(recovered)
const store = makeStore()
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
registerCreatedWorktreeRoot(store, repo.id, recovered)
return []
})
@@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => {
it('retires a recovered root once the listing can see it again', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
vi.mocked(listRepoWorktrees).mockResolvedValue([
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([
{
path: '/linked/feature',
head: '',
@@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => {
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
resolve('/linked/feature')
)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
@@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => {
}))
let active = 0
let maxActive = 0
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
active += 1
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, 1))
@@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => {
await rebuildAuthorizedRootsCache(makeStore(repos))
expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length)
expect(maxActive).toBeLessThanOrEqual(8)
})
})
@@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => {
vi.resetModules()
vi.doMock('../repo-worktrees', () => ({
isRepoRoot: vi.fn(),
listRepoWorktrees: vi.fn()
listRepoWorktreeGraph: vi.fn()
}))
vi.doMock('path', async () => {
const path = await vi.importActual<typeof NodePath>('node:path')
+1
View File
@@ -107,6 +107,7 @@ export const gitStatusModuleMock = {
export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock }
export const gitWorktreeMock = {
listWorktreeGraph: listWorktreesMock,
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock
}
@@ -5,7 +5,7 @@ import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/comm
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
import { resolve } from 'node:path'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { listRepoWorktrees } from '../../repo-worktrees'
import { listRepoWorktreeGraph } from '../../repo-worktrees'
import { resolveAuthorizedPath } from '../filesystem-auth'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import { splitWorktreeId } from '../../../shared/worktree/id'
@@ -77,7 +77,7 @@ async function localRepoOwnsWorktree(
return true
}
try {
const worktrees = await listRepoWorktrees(repo)
const worktrees = await listRepoWorktreeGraph(repo)
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
} catch {
return false
@@ -9,6 +9,10 @@ import {
import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache'
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation'
import {
materializeWorktreePushTargetRemote,
materializeWorktreePushTargetRemoteSsh
} from '../../worktree-remote'
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void {
@@ -20,6 +24,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
_event,
args: {
worktreePath: string
worktreeId?: string
publish?: boolean
forceWithLease?: boolean
connectionId?: string
@@ -36,7 +41,18 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.pushBranch(args.worktreePath, publish, args.pushTarget, {
// Why: a fork remote deferred at create time (#17828) must exist before push.
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return provider.pushBranch(args.worktreePath, publish, materializedPushTarget, {
forceWithLease: args.forceWithLease === true
})
}
@@ -46,13 +62,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
args.worktreePath,
worktreePath
)
if (args.pushTarget) {
await validateGitPushTarget(worktreePath, args.pushTarget, {
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemote(
worktreePath,
args.pushTarget,
store,
undefined,
gitOptions,
args.worktreeId
)
: undefined
if (materializedPushTarget) {
await validateGitPushTarget(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
}
await gitPush(worktreePath, publish, args.pushTarget, {
await gitPush(worktreePath, publish, materializedPushTarget, {
forceWithLease: args.forceWithLease === true,
...gitOptions,
admissionTier: 'interactive'
@@ -64,7 +90,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
'git:pull',
async (
_event,
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
args: {
worktreePath: string
worktreeId?: string
connectionId?: string
pushTarget?: GitPushTarget
}
): Promise<void> => {
if (args.connectionId) {
if (args.pushTarget) {
@@ -74,7 +105,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.pullBranch(args.worktreePath, args.pushTarget)
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return provider.pullBranch(args.worktreePath, materializedPushTarget)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -82,13 +123,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
args.worktreePath,
worktreePath
)
if (args.pushTarget) {
await validateGitPushTarget(worktreePath, args.pushTarget, {
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemote(
worktreePath,
args.pushTarget,
store,
undefined,
gitOptions,
args.worktreeId
)
: undefined
if (materializedPushTarget) {
await validateGitPushTarget(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
}
await gitPull(worktreePath, args.pushTarget, {
await gitPull(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
@@ -99,7 +150,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
'git:fastForward',
async (
_event,
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
args: {
worktreePath: string
worktreeId?: string
connectionId?: string
pushTarget?: GitPushTarget
}
): Promise<void> => {
if (args.connectionId) {
if (args.pushTarget) {
@@ -109,7 +165,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.fastForwardBranch(args.worktreePath, args.pushTarget)
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return provider.fastForwardBranch(args.worktreePath, materializedPushTarget)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -117,13 +183,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl
args.worktreePath,
worktreePath
)
if (args.pushTarget) {
await validateGitPushTarget(worktreePath, args.pushTarget, {
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemote(
worktreePath,
args.pushTarget,
store,
undefined,
gitOptions,
args.worktreeId
)
: undefined
if (materializedPushTarget) {
await validateGitPushTarget(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
}
await gitFastForward(worktreePath, args.pushTarget, {
await gitFastForward(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
@@ -17,6 +17,10 @@ import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-c
import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options'
import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation'
import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync'
import {
materializeWorktreePushTargetRemote,
materializeWorktreePushTargetRemoteSsh
} from '../../worktree-remote'
import type { FilesystemHandlerContext } from '../filesystem-handler-context'
export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void {
@@ -52,7 +56,12 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
'git:fetch',
async (
_event,
args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget }
args: {
worktreePath: string
worktreeId?: string
connectionId?: string
pushTarget?: GitPushTarget
}
): Promise<void> => {
if (args.connectionId) {
if (args.pushTarget) {
@@ -62,7 +71,17 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.fetchRemote(args.worktreePath, args.pushTarget)
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemoteSsh(
provider,
args.worktreePath,
args.pushTarget,
store,
undefined,
args.worktreeId
)
: undefined
return provider.fetchRemote(args.worktreePath, materializedPushTarget)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -70,13 +89,23 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext)
args.worktreePath,
worktreePath
)
if (args.pushTarget) {
await validateGitPushTarget(worktreePath, args.pushTarget, {
const materializedPushTarget = args.pushTarget
? await materializeWorktreePushTargetRemote(
worktreePath,
args.pushTarget,
store,
undefined,
gitOptions,
args.worktreeId
)
: undefined
if (materializedPushTarget) {
await validateGitPushTarget(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
}
await gitFetch(worktreePath, args.pushTarget, {
await gitFetch(worktreePath, materializedPushTarget, {
...gitOptions,
admissionTier: 'interactive'
})
+8 -8
View File
@@ -17,7 +17,7 @@ const {
getHostedReviewCreationEligibilityMock,
getHostedReviewForBranchMock,
resolveRegisteredWorktreePathMock,
listRepoWorktreesMock
listRepoWorktreeGraphMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
createHostedReviewMock: vi.fn(),
@@ -25,7 +25,7 @@ const {
getHostedReviewCreationEligibilityMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
resolveRegisteredWorktreePathMock: vi.fn(),
listRepoWorktreesMock: vi.fn()
listRepoWorktreeGraphMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({
}))
vi.mock('../repo-worktrees', () => ({
listRepoWorktrees: listRepoWorktreesMock
listRepoWorktreeGraph: listRepoWorktreeGraphMock
}))
import { registerHostedReviewHandlers } from './hosted-review'
@@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => {
getHostedReviewCreationEligibilityMock.mockReset()
getHostedReviewForBranchMock.mockReset()
resolveRegisteredWorktreePathMock.mockReset()
listRepoWorktreesMock.mockReset()
listRepoWorktreeGraphMock.mockReset()
store.getRepo.mockReset()
store.getRepos.mockReset()
store.getProjects.mockReset()
@@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => {
store.getRepos.mockReturnValue([repo])
store.getProjects.mockReturnValue([])
store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }])
})
it('routes local WSL project review creation through main-process runtime options', async () => {
@@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => {
])
const resolvedWorktreePath = resolve('/workspace/feature')
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
createHostedReviewMock.mockResolvedValueOnce({
ok: true,
number: 42,
@@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => {
title: 'Feature PR'
})
expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
expect(createHostedReviewMock).toHaveBeenCalledWith(
resolvedWorktreePath,
expect.objectContaining({
@@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => {
store.getRepos.mockReturnValue([localRepo])
const resolvedWorktreePath = resolve('/workspace/feature')
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' })
registerHostedReviewHandlers(store as never, stats as never)
+4 -4
View File
@@ -16,7 +16,7 @@ import {
import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
import { getRepoExecutionHostId } from '../../shared/execution-host'
@@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath(
}
if (repo.connectionId) {
const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath)
const repoWorktrees = await listRepoWorktrees(repo)
const repoWorktrees = await listRepoWorktreeGraph(repo)
if (
!repoWorktrees.some(
(worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath
@@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath(
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
const repoWorktrees =
Object.keys(localGitOptions).length > 0
? await listRepoWorktrees(repo, localGitOptions)
: await listRepoWorktrees(repo)
? await listRepoWorktreeGraph(repo, localGitOptions)
: await listRepoWorktreeGraph(repo)
if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) {
throw new Error('Access denied: worktree does not belong to repository')
}
@@ -0,0 +1,149 @@
// Real-binary coverage for #17828's remaining gap: the mocked-underlying-trigger suite in
// `spawn-push-target-materialization.test.ts` proves the wiring/delegation logic, but not
// that a `pty:spawn`-originated terminal -- the desktop GUI's own terminal path, previously
// uncovered -- actually ends up with a configured upstream against real git. No mocks here:
// this exercises the real `triggerTerminalSpawnPushTargetMaterialization` and real
// `materializeWorktreePushTargetRemote`, driven only through `runPtyIpcSpawn`'s hook.
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { Store } from '../../../persistence'
import type { PtySpawnIpcDeps } from './spawn-types'
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
const execFileAsync = promisify(execFile)
const REPO_ID = 'repo-1'
const FORK_REMOTE = 'pr-contributor-orca'
const TRACKED_BRANCH = 'contributor/fix'
let scratchDir = ''
let repoPath = ''
let forkPath = ''
let worktreeId = ''
let mainBranch = ''
async function git(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function setIdentity(cwd: string): Promise<void> {
await git(['config', 'user.name', 'Orca Test'], cwd)
await git(['config', 'user.email', 'orca@example.test'], cwd)
await git(['config', 'commit.gpgSign', 'false'], cwd)
}
beforeEach(async () => {
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/...
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pty-spawn-push-target-')))
repoPath = join(scratchDir, 'repo')
forkPath = join(scratchDir, 'fork')
worktreeId = `${REPO_ID}::${repoPath}`
await mkdir(repoPath, { recursive: true })
await git(['init', '-q'], repoPath)
await setIdentity(repoPath)
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
await git(['add', '-A'], repoPath)
await git(['commit', '-qm', 'seed'], repoPath)
mainBranch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).trim()
await git(['clone', '-q', repoPath, forkPath], scratchDir)
await setIdentity(forkPath)
await git(['checkout', '-qb', TRACKED_BRANCH], forkPath)
await writeFile(join(forkPath, 'fix.txt'), 'fix\n')
await git(['add', '-A'], forkPath)
await git(['commit', '-qm', 'fix'], forkPath)
})
afterEach(async () => {
await rm(scratchDir, { recursive: true, force: true })
})
function forkTarget(): GitPushTarget {
return { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath }
}
function depsFor(
pushTarget: GitPushTarget,
setWorktreeMeta?: Store['setWorktreeMeta']
): {
deps: PtySpawnIpcDeps
meta: Record<string, WorktreeMeta>
} {
const meta: Record<string, WorktreeMeta> = { [worktreeId]: { pushTarget } as WorktreeMeta }
const store = {
getWorktreeMeta: (id: string) => meta[id],
getRepo: (id: string) => ({ id, path: repoPath, connectionId: null }) as unknown as Repo,
getAllWorktreeMeta: () => meta,
...(setWorktreeMeta ? { setWorktreeMeta } : {})
} as unknown as Store
return { deps: { store } as unknown as PtySpawnIpcDeps, meta }
}
describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => {
it('materializes the fork remote and configures the upstream for a pty:spawn-originated terminal', async () => {
// Why: not just that materialization was *called* -- the coordinator's bar for closing
// the gap is a real, git-verified configured upstream reachable from a pty:spawn arg set.
// Pre-seeds the remote so materialize takes the short-circuit branch (worktree-remote.ts):
// real `remote add`/`fetch` against a fabricated fork is already covered against real git by
// worktree-push-target-refspec-real-git.test.ts; the top-level entry point this hook calls
// additionally validates `remoteUrl` against a GitHub URL shape, which a local fixture path
// can never satisfy. The short-circuit is also the common case in practice -- every pty:spawn
// after the worktree's first (new tab, split, reattach) -- and still drives real
// `ensureRemoteTracksBranchNarrowly` / narrow `fetch` / `--set-upstream-to` git calls.
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
const { deps } = depsFor(forkTarget())
triggerPtySpawnPushTargetMaterialization(deps, {
cols: 80,
rows: 24,
worktreeId
})
await vi.waitFor(
async () => {
const upstream = await git(
['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`],
repoPath
).catch(() => '')
expect(upstream.trim()).toBe(`${FORK_REMOTE}/${TRACKED_BRANCH}`)
},
{ timeout: 5000, interval: 25 }
)
const remoteUrl = (await git(['remote', 'get-url', FORK_REMOTE], repoPath)).trim()
expect(remoteUrl).toBe(forkPath)
// The tracked branch's commit must actually be present -- confirms the narrow fetch ran,
// not just that the remote config was written.
const forkHead = (await git(['rev-parse', TRACKED_BRANCH], forkPath)).trim()
const fetchedHead = (
await git(['rev-parse', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath)
).trim()
expect(fetchedHead).toBe(forkHead)
})
it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => {
const target = { ...forkTarget(), remoteCreated: true }
const { deps } = depsFor(target)
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId })
// Give the fire-and-forget chain a tick; there is nothing to wait for since a
// remoteCreated target must short-circuit before any git call.
await new Promise((resolve) => setImmediate(resolve))
const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch(
() => ''
)
expect(upstream.trim()).toBe('')
})
})
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import type { Store } from '../../../persistence'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
const { triggerMock } = vi.hoisted(() => ({ triggerMock: vi.fn() }))
vi.mock('../../../runtime/runtime-terminal-spawn-push-target-materialization', () => ({
triggerTerminalSpawnPushTargetMaterialization: triggerMock
}))
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
const REPO_ID = 'repo-1'
const WORKTREE_PATH = '/repo/worktree'
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
const FORK_TARGET: GitPushTarget = {
remoteName: 'pr-contributor-orca',
branchName: 'contributor/fix',
remoteUrl: 'git@github.com:contributor/orca.git'
}
const REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo
function depsWithStore(overrides: Partial<Store> = {}): PtySpawnIpcDeps {
return {
store: {
getWorktreeMeta: vi.fn().mockReturnValue({ pushTarget: FORK_TARGET } as WorktreeMeta),
getRepo: vi.fn().mockReturnValue(REPO),
...overrides
} as unknown as Store
} as unknown as PtySpawnIpcDeps
}
function baseArgs(overrides: Partial<PtySpawnIpcArgs> = {}): PtySpawnIpcArgs {
return { cols: 80, rows: 24, worktreeId: WORKTREE_ID, ...overrides }
}
describe('triggerPtySpawnPushTargetMaterialization', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
triggerMock.mockReset()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('is a no-op when args has no worktreeId', () => {
triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs({ worktreeId: undefined }))
expect(triggerMock).not.toHaveBeenCalled()
})
it('is a no-op when deps has no store', () => {
triggerPtySpawnPushTargetMaterialization({} as unknown as PtySpawnIpcDeps, baseArgs())
expect(triggerMock).not.toHaveBeenCalled()
})
it('is a no-op for a malformed worktreeId (no separator)', () => {
triggerPtySpawnPushTargetMaterialization(
depsWithStore(),
baseArgs({ worktreeId: 'not-a-valid-id' })
)
expect(triggerMock).not.toHaveBeenCalled()
})
it('parses the worktreeId, looks up the push target and repo, and delegates', () => {
const deps = depsWithStore()
triggerPtySpawnPushTargetMaterialization(deps, baseArgs())
expect(deps.store!.getWorktreeMeta).toHaveBeenCalledWith(WORKTREE_ID)
expect(deps.store!.getRepo).toHaveBeenCalledWith(REPO_ID)
expect(triggerMock).toHaveBeenCalledWith(
WORKTREE_PATH,
FORK_TARGET,
REPO,
deps.store,
REPO_ID,
WORKTREE_ID
)
})
it('passes null when the repo lookup misses', () => {
const deps = depsWithStore({ getRepo: vi.fn().mockReturnValue(undefined) })
triggerPtySpawnPushTargetMaterialization(deps, baseArgs())
expect(triggerMock).toHaveBeenCalledWith(
WORKTREE_PATH,
FORK_TARGET,
null,
deps.store,
REPO_ID,
WORKTREE_ID
)
})
// Why: many pty:spawn unit tests supply a narrow fake Store missing these methods --
// this is the actual bug the hook must guard against (#17828), not a hypothetical.
// Optional chaining degrades the lookups to undefined/null; the underlying trigger
// itself no-ops on an undefined push target, so this never blocks or throws on spawn.
it('does not throw when the store lacks getWorktreeMeta/getRepo, delegating with undefined/null', () => {
const partialStore = {} as Store
expect(() =>
triggerPtySpawnPushTargetMaterialization(
{ store: partialStore } as unknown as PtySpawnIpcDeps,
baseArgs()
)
).not.toThrow()
expect(triggerMock).toHaveBeenCalledWith(
WORKTREE_PATH,
undefined,
null,
partialStore,
REPO_ID,
WORKTREE_ID
)
})
it('warns and swallows an error thrown by the underlying trigger', () => {
triggerMock.mockImplementation(() => {
throw new Error('boom')
})
expect(() =>
triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs())
).not.toThrow()
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('failed to trigger push target materialization'),
expect.any(Error)
)
})
it('strips a folder-workspace instance suffix from the worktree path before delegating', () => {
const instanceId = 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f789'
const deps = depsWithStore()
const suffixedId = `${WORKTREE_ID}::workspace:${instanceId}`
triggerPtySpawnPushTargetMaterialization(deps, baseArgs({ worktreeId: suffixedId }))
expect(triggerMock).toHaveBeenCalledWith(
WORKTREE_PATH,
FORK_TARGET,
REPO,
deps.store,
REPO_ID,
suffixedId
)
})
})
@@ -0,0 +1,40 @@
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id'
import { triggerTerminalSpawnPushTargetMaterialization } from '../../../runtime/runtime-terminal-spawn-push-target-materialization'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
// Why (#17828): pty:spawn is the desktop GUI's own terminal path (new tab, split, reattach) --
// raw git commands can run here before any Orca-driven sync, so a deferred fork-PR remote must
// exist first. Mirrors the agent/background-terminal hook in
// runtime-terminal-spawn-push-target-materialization.ts, which this delegates to; fire-and-forget
// and a no-op once the remote already exists, so it is safe on every spawn including reattaches.
export function triggerPtySpawnPushTargetMaterialization(
deps: PtySpawnIpcDeps,
args: PtySpawnIpcArgs
): void {
if (!args.worktreeId || !deps.store) {
return
}
const parsed = splitWorktreeIdForFilesystem(args.worktreeId)
if (!parsed) {
return
}
// Why: never let a partial/fake Store (many pty:spawn unit tests supply a narrow one) or an
// unexpected lookup failure turn this best-effort hook into a spawn-blocking exception.
try {
const pushTarget = deps.store.getWorktreeMeta?.(args.worktreeId)?.pushTarget
const repo = deps.store.getRepo?.(parsed.repoId) ?? null
triggerTerminalSpawnPushTargetMaterialization(
parsed.worktreePath,
pushTarget,
repo,
deps.store,
parsed.repoId,
args.worktreeId
)
} catch (error) {
console.warn(
`[pty-spawn] failed to trigger push target materialization for ${args.worktreeId}:`,
error
)
}
}
+2
View File
@@ -7,6 +7,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options'
import { executePtyIpcSpawn } from './spawn-execute'
import { commitPtyIpcSpawn } from './spawn-commit'
import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state'
import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization'
import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void {
@@ -30,6 +31,7 @@ function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void {
}
export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) {
triggerPtySpawnPushTargetMaterialization(deps, args)
const ctx = createPtyIpcSpawnState(deps, args)
const early = await beginPtyIpcSpawn(ctx)
if (early) {
@@ -3,7 +3,7 @@ import { resolve } from 'node:path'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import { getErrorCode } from '../git/worktree-operation-options'
import type { Store } from '../persistence'
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees'
import { getLocalRepos } from './filesystem-allowed-roots'
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
@@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
try {
roots.push(resolve(repo.path))
for (const worktree of await listRepoWorktrees(repo)) {
for (const worktree of await listRepoWorktreeGraph(repo)) {
roots.push(resolve(worktree.path))
}
} catch (error) {
@@ -113,7 +113,7 @@ describe('repos:add with git worktrees', () => {
invalidateAuthorizedRootsCacheMock.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('returns the tracked main checkout instead of adding its linked worktree', async () => {
+5 -2
View File
@@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({
rm: rmMock
}))
// `availableParallelism` is read at module load by the git admission scheduler,
// which this module graph reaches; a partial `os` mock breaks that import.
vi.mock('os', () => ({
homedir: homedirMock
homedir: homedirMock,
availableParallelism: () => 8
}))
vi.mock('../git/runner', () => ({
@@ -148,7 +151,7 @@ describe('repos:create', () => {
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
homedirMock.mockReset().mockReturnValue('/Users/alice')
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('registers the repos:create handler', () => {
@@ -54,7 +54,7 @@ describe('projectGroups IPC validation', () => {
mockWindow.webContents.send.mockReset()
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('rejects malformed local project group create arguments before persistence', () => {
@@ -55,7 +55,7 @@ describe('repos:add + repos:clone', () => {
resetLocalRepoMocks(reposMocks)
mockWindow.webContents.send.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => {
@@ -73,7 +73,7 @@ describe('repos:add + repos:clone', () => {
resetLocalRepoMocks(reposMocks)
mockWindow.webContents.send.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
afterEach(async () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe('projectGroups IPC validation', () => {
mockWindow.webContents.send.mockReset()
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('uses completed scan ids as an allowlist for nested imports', async () => {
+1 -1
View File
@@ -52,7 +52,7 @@ describe('projectGroups IPC validation', () => {
mockWindow.webContents.send.mockReset()
resetProjectGroupMocks(reposMocks, { isGitRepo, getGitRepoRoot })
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('scans nested repositories over a connected SSH filesystem', async () => {
+1 -1
View File
@@ -80,7 +80,7 @@ describe('repos folder pickers', () => {
removeHandlerMock.mockReset()
showOpenDialogMock.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('registers the multi-folder picker with handler cleanup', () => {
@@ -51,7 +51,7 @@ describe('repos:getBaseRefDefault envelope', () => {
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
// Reset exec so a newly added test doesn't inherit the previous test's exec mock.
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('returns { defaultBaseRef, remoteCount: 0 } for folder-mode repos', async () => {
@@ -235,7 +235,7 @@ describe('repos:searchBaseRefs SSH relay', () => {
mockStore.getRepo.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('returns [] for a folder-mode repo without invoking the relay', async () => {
@@ -48,7 +48,7 @@ const mainWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } }
async function registerHandlersWithoutNotifier(): Promise<typeof ReposChangedNotificationModule> {
vi.resetModules()
const repos = await import('./repos')
repos.registerRepoHandlers(mainWindow as never, mockStore as never)
repos.registerRepoHandlers(mainWindow as never, mockStore as never, {} as never)
return import('./repos/repos-changed-notification')
}
@@ -50,7 +50,7 @@ describe('repos:getGitUsername', () => {
mockWindow.webContents.send.mockReset()
prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined)
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('uses explicit SSH username config instead of remote author identity', async () => {
+1 -1
View File
@@ -98,7 +98,7 @@ describe('repos:addRemote', () => {
})
mockWindow.webContents.send.mockReset()
registerRepoHandlers(mockWindow as never, mockStore as never)
registerRepoHandlers(mockWindow as never, mockStore as never, {} as never)
})
it('registers the repos:addRemote handler', () => {
+1 -1
View File
@@ -96,7 +96,7 @@ describe('sparse preset repo IPC handlers', () => {
mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset)
mockStore.removeSparsePreset.mockReset()
registerRepoHandlers(mainWindow as never, mockStore as never)
registerRepoHandlers(mainWindow as never, mockStore as never, {} as never)
})
it('normalizes and de-duplicates saved sparse preset directories', () => {
+7 -2
View File
@@ -13,8 +13,13 @@ import { registerRepoFolderPickerHandlers } from './repos/repo-folder-picker-han
import { registerRepoCloneHandlers } from './repos/repo-clone-lifecycle'
import { registerRepoGitUsernameHandler } from './repos/repo-git-username-handler'
import { registerBaseRefQueryHandlers } from './repos/base-ref-query-handlers'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
export function registerRepoHandlers(
mainWindow: BrowserWindow,
store: Store,
runtime: OrcaRuntimeService
): void {
// Remove previously registered handlers so we can re-register on macOS app re-activation (new window).
ipcMain.removeHandler('repos:list')
ipcMain.removeHandler('repos:listForExecutionHost')
@@ -67,7 +72,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
registerProjectHostSetupHandlers(mainWindow, store)
registerRepoCreationHandlers(mainWindow, store)
registerProjectGroupHandlers(mainWindow, store)
registerFolderWorkspaceHandlers(mainWindow, store)
registerFolderWorkspaceHandlers(mainWindow, store, runtime)
registerNestedRepoImportHandler(mainWindow, store)
registerRepoUpdateHandler(mainWindow, store)
registerSparsePresetHandlers(mainWindow, store)
@@ -9,6 +9,7 @@ import {
getFolderWorkspacePathStatusForPath
} from '../../project-groups/folder-workspace-path-status'
import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import type { OrcaRuntimeService } from '../../runtime/orca-runtime'
import { notifyReposChanged } from './repos-changed-notification'
import {
FolderWorkspaceCreateArgs,
@@ -18,7 +19,11 @@ import {
parseProjectGroupIpcArgs
} from './repo-ipc-arg-schemas'
export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store: Store): void {
export function registerFolderWorkspaceHandlers(
mainWindow: BrowserWindow,
store: Store,
runtime: OrcaRuntimeService
): void {
ipcMain.handle('folderWorkspaces:list', (): FolderWorkspace[] => store.getFolderWorkspaces())
ipcMain.handle('folderWorkspaces:getPathStatus', async (_event, rawArgs: unknown) => {
@@ -107,16 +112,13 @@ export function registerFolderWorkspaceHandlers(mainWindow: BrowserWindow, store
}
)
ipcMain.handle('folderWorkspaces:delete', (_event, rawArgs: unknown): boolean => {
ipcMain.handle('folderWorkspaces:delete', async (_event, rawArgs: unknown): Promise<boolean> => {
const args = parseProjectGroupIpcArgs(
FolderWorkspaceSelectorArgs,
rawArgs,
'invalid_folder_workspace_delete_args'
)
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
if (deleted) {
notifyReposChanged(mainWindow)
}
return deleted
// Why: the runtime owns PTY/browser/session teardown and notifies on success.
return (await runtime.deleteFolderWorkspace(args.folderWorkspaceId)).deleted
})
}
+18 -2
View File
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types'
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
@@ -60,14 +60,21 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise<void>
assertSshConnectsNotFenced()
conn = await connectionManager!.connect(target)
}
let relayStopAcknowledged = false
try {
await forceStopRelayForTarget(conn, targetId)
relayStopAcknowledged = true
} finally {
const ptyIds = new Set(getPtyIdsForConnection(targetId))
for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) {
if (lease.state !== 'terminated' && lease.state !== 'expired') {
ptyIds.add(lease.ptyId)
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
// Why: only a host-acknowledged force-stop may retire a lease. When it threw we never
// observed those shells, so expiring them would record a verdict we do not hold; mirrors
// ssh:terminateSessions, and the next connect re-attaches (or expires) them on evidence.
if (relayStopAcknowledged) {
persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired')
}
}
}
// Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM.
@@ -98,6 +105,9 @@ export function registerSshConnectionHandlers(): void {
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
invalidateConnectAttempt(args.targetId)
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
await runTargetLifecycle(args.targetId, async () => {
const provider = getSshPtyProvider(args.targetId)
const leases = persistedStore!.getSshRemotePtyLeases(args.targetId)
@@ -142,6 +152,10 @@ export function registerSshConnectionHandlers(): void {
)
)
: []
if (!provider) {
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
outcome = { terminated: 0, unverifiable: ptyIds.length }
}
const shutdownFailures: string[] = []
for (const [index, result] of shutdownResults.entries()) {
const { appPtyId, relayPtyId } = ptyIds[index]
@@ -154,6 +168,7 @@ export function registerSshConnectionHandlers(): void {
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
outcome = { ...outcome, terminated: outcome.terminated + 1 }
}
if (shutdownFailures.length > 0) {
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
@@ -161,6 +176,7 @@ export function registerSshConnectionHandlers(): void {
}
await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist())
})
return outcome
})
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
@@ -77,6 +77,38 @@ describe('SSH IPC handlers', () => {
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
})
// A force-stop that threw observed nothing about the remote shells, so expiring their leases
// would record a verdict Orca never obtained (docs/reference/ssh-execution-boundary.md).
it('ssh:resetRelay keeps leases alive when the force-stop never reported a result', async () => {
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const conn = {}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue(conn)
mockConnectionManager.getConnection.mockReturnValue(undefined)
mockStore.getSshRemotePtyLeases.mockReturnValue([
{ targetId: 'ssh-1', ptyId: 'pty-1', state: 'detached' },
{ targetId: 'ssh-1', ptyId: 'pty-2', state: 'attached' }
])
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
mockForceStopRelayForTarget.mockRejectedValueOnce(new Error('channel closed'))
await expect(handlers.get('ssh:resetRelay')!(null, { targetId: 'ssh-1' })).rejects.toThrow(
'channel closed'
)
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled()
// The local handles are still stale — only the host-side verdict is withheld.
expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:ssh-1@@pty-1')
expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:ssh-1@@pty-2')
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
})
it('ssh:resetRelay clears scoped live PTYs while expiring raw leases', async () => {
const target: SshTarget = {
id: 'ssh-1',
+23 -3
View File
@@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => {
mockPtyProvider.shutdown.mockResolvedValue(undefined)
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toEqual({ terminated: 2, unverifiable: 0 })
expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', {
immediate: true,
@@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => {
await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' })
})
it('ssh:terminateSessions cannot reach expired leases without a relay', async () => {
// Issue #12661: an offline sweep tears down local transport only. Reporting plain success would
// read as "the remote shells are gone" when nobody asked the host.
it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => {
mockStore.getSshRemotePtyLeases.mockReturnValue([
{ targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }
])
@@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => {
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toBeUndefined()
).resolves.toEqual({ terminated: 0, unverifiable: 1 })
expect(mockPtyProvider.shutdown).not.toHaveBeenCalled()
// Still no forced reconnect: an expired lease can name a host that is gone for good (#2626).
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'ssh-1',
'pty-expired',
'terminated'
)
})
it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => {
mockStore.getSshRemotePtyLeases.mockReturnValue([])
vi.mocked(getSshPtyProvider).mockReturnValue(undefined)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
await expect(
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
).resolves.toEqual({ terminated: 0, unverifiable: 0 })
})
it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => {
+2
View File
@@ -24,7 +24,9 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'daemon_adopted',
'daemon_audit_eligibility',
'daemon_pty_cwd_denied',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
@@ -0,0 +1,289 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollerOptions,
WorktreeBasePollEvent,
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
})
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
export async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
// Why: the backstop full scan stats a `.git` marker per candidate dir; an
// unbounded fan-out at hundreds of worktrees would queue thousands of `stat`
// calls on libuv's 4-thread pool (#17828).
const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
try {
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
function makeTarget(path: string): WorktreeBaseWatchTarget {
const repoConfig: WorktreeBaseRepoWatchConfig = {
repoId: 'repo-1',
repoName: 'project',
nestWorkspaces: false
}
return {
key: `base:local:${path}`,
kind: 'base',
path,
repos: new Map([[repoConfig.repoId, repoConfig]])
}
}
describe('worktree base directory poller marker fan-out (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
beforeEach(() => {
concurrency.current = 0
concurrency.peak = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-')))
cleanups.push(() => rm(root, { recursive: true, force: true }))
const candidateCount = 200
for (let i = 0; i < candidateCount; i++) {
const worktree = join(root, `wt-${i}`)
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
}
const target = makeTarget(root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{ pollIntervalMs: 100_000 }
)
cleanups.push(() => poller.unsubscribe())
// 200 candidates stated unbounded would peak near 200 concurrent `stat`
// calls; bounding the marker probe keeps the peak independent of count —
// while still overlapping requests (not serialized one-at-a-time).
expect(concurrency.peak).toBeGreaterThan(1)
expect(concurrency.peak).toBeLessThan(20)
})
})
+7 -275
View File
@@ -1,13 +1,13 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import { startBasePoller } from './worktree-base-directory-marker-poller'
import { startGitCommonWatch } from './worktree-git-common-watch'
export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller'
export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string }
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
@@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = {
visibility?: WorktreePollerWindowVisibility
getGitStatusRefPaths?: () => readonly string[]
onWatchError?: (error: Error) => void
/** Called when the watcher child dropped an event batch (git-common narrow watch only). */
onOverflow?: () => void
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
/** Test hook: called before a pending `.git` marker stat. */
@@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = {
// Orca's own worktree operations notify the renderer directly.
export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
let rootEntries
try {
rootEntries = await readdir(rootPath, { withFileTypes: true })
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs, gateSignatures }
}
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
} catch {
subEntries = []
}
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
for (const dir of candidates) {
markers.set(dir, await hasGitMarker(dir))
}
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
/** Watches the shallow paths a worktree base target cares about and emits
* watcher-shaped events. Resolves once the baseline (snapshot or narrow
* native subscription) is established. */
@@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller(
visibility,
options.onFullScan,
options.getGitStatusRefPaths,
options.onWatchError
options.onWatchError,
options.onOverflow
)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
@@ -0,0 +1,90 @@
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
scheduleWorktreeBaseNotification,
type WorktreeBaseNotificationWatch
} from './worktree-base-directory-notifications'
import {
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths
} from './worktree-git-status-ref-watch'
import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
export type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
export function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
getActiveWatches
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
// Why: after a dropped event batch nothing about the prior state can be
// trusted — widen unconditionally (structural + status + head-identity),
// same shape as the remote overflow branch below, bypassing the watcher-error
// cooldown so a burst of overflows during one bulk op cannot suppress the
// refresh the fleet actually needs.
export function handleWatchOverflow(
watch: ActiveWatch,
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
export function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
getActiveWatches
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
handleWatchOverflow(watch, getActiveWatches)
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
@@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreesChanged).toHaveBeenCalledOnce()
})
it('widens an overflowed local git-common watch to a structural refresh', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
const request = {
worktreeId: `repo-1::${PROJECT_ROOT}`,
worktreePath: PROJECT_ROOT,
executionHostId: 'local',
branch: 'refs/heads/feature',
upstreamName: 'origin/feature'
}
const resolve = vi.fn(async () => 'refs/remotes/origin/feature')
await setWorktreeGitStatusRefWatch(request, resolve)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
// Overflow is definite proof of loss, not a possibly-transient error — it
// invalidates the cached ref resolution unconditionally.
await setWorktreeGitStatusRefWatch(request, resolve)
expect(resolve).toHaveBeenCalledTimes(2)
})
it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
// A watcher-error burst within the 60s cooldown window collapses to one
// refresh (see "throttles repeated structural refreshes from watcher
// failures" above); overflow must not inherit that gate, since a bulk op
// can legitimately overflow more than once before it settles.
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2)
})
it('keeps linked HEAD and lock metadata structural', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+16 -72
View File
@@ -6,16 +6,9 @@ import {
disposeWorktreeHeadIdentityRefreshState,
refreshWorktreeHeadIdentities
} from './worktree-head-identity-refresh'
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
clearPendingWorktreeBaseNotifications,
scheduleWorktreeBaseNotification,
supportsWorktreeHeadIdentityRefresh,
type WorktreeBaseNotificationWatch
supportsWorktreeHeadIdentityRefresh
} from './worktree-base-directory-notifications'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope'
@@ -30,18 +23,16 @@ import {
import {
applyActiveGitStatusRefBinding,
clearActiveGitStatusRefBinding,
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths,
updateActiveGitStatusRefBinding,
type GitStatusRefBindingRequest
} from './worktree-git-status-ref-watch'
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
import {
handleLocalWatchEvents,
handleRemoteWatchEvents,
handleWatchOverflow,
type ActiveWatch
} from './worktree-base-directory-watch-events'
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
@@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch(
return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef)
}
function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
() => activeWatches.values()
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
() => activeWatches.values()
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function createActiveWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
@@ -146,7 +84,7 @@ async function subscribeTarget(
if (!currentWatch || currentWatch.disposed) {
return
}
handleRemoteWatchEvents(currentWatch, events)
handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values())
})
activeWatch = createActiveWatch(
target,
@@ -167,7 +105,7 @@ async function subscribeTarget(
(events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, null, events)
handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values())
}
},
{
@@ -178,7 +116,13 @@ async function subscribeTarget(
onWatchError: (error) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, error, [])
handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values())
}
},
onOverflow: () => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch) {
handleWatchOverflow(currentWatch, () => activeWatches.values())
}
}
}
@@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry(
previous: GitCommonEntrySnapshot | undefined,
forceFullScan: boolean
): Promise<GitCommonEntrySnapshot> {
// Structural leaves change in place every tick; only index uses the entry-dir gate.
// Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the
// entry dir, so the entry dir's own signature moves on every one of those writes
// (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash,
// worktree lock/unlock, config --worktree, index writes all move it). The one
// in-place exception is `gitdir` (worktree move/repair), which the periodic
// forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this
// gate. Gating all of these leaves on the entry-dir signature turns an unchanged
// entry into a single stat per tick instead of stat-ing every leaf every tick.
const nextDirSignature = await gitCommonDirectorySignature(entryPath)
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures: new Map(),
indexSignature: null,
headLogSignature: null
}
)
}
const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
if (!shouldRescan) {
return previous
}
const structuralSignatures = new Map<string, string>()
const [nextDirSignature, headLogSignature] = await Promise.all([
gitCommonDirectorySignature(entryPath),
const [headLogSignature, indexSignature] = await Promise.all([
gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)),
gitCommonFileSignature(join(entryPath, INDEX_FILE)),
Promise.all(
STRUCTURAL_METADATA_FILES.map(async (name) => {
const signature = await gitCommonFileSignature(join(entryPath, name))
@@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry(
})
)
])
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures,
indexSignature: null,
headLogSignature
}
)
}
const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
const indexSignature = shouldReadIndex
? await gitCommonFileSignature(join(entryPath, INDEX_FILE))
: previous.indexSignature
return {
dirSignature: nextDirSignature,
structuralSignatures,
@@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch(
platform: NodeJS.Platform,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
onWatchError?: (error: Error) => void
onWatchError?: (error: Error) => void,
// Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a
// harder loss signal than a transient error — nothing about the prior state
// can be trusted, so this bypasses onWatchError's failure cooldown instead
// of reusing it.
onOverflow?: () => void
): Promise<WorktreeBaseSubscription> {
const worktreesDir = join(target.path, 'worktrees')
const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {}
@@ -73,6 +78,11 @@ export async function startGitCommonNarrowWatch(
.unsubscribe()
.catch(() => {})
.then(() =>
// Crash fuse tripped: this poller is now the sole change signal until a
// future existence-poll upgrade (follow-up: #17878). Its own per-entry
// dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps
// an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence
// stays cheap at high worktree counts without needing to stretch itself.
startGitCommonPolling(
target.path,
onEvents,
@@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch(
onEvents([{ type: 'update', path: worktreesDir }])
}
}
},
// Why: the watcher child drops the whole batch past 5,000 events
// (native FSEvents overflow maps to the same op) instead of reporting
// which paths changed. Unlike a transient error, this is definite
// proof of loss, so it always widens rather than falling back to the
// failure-cooldown-gated onWatchError path.
onOverflow: () => {
if (disposed || !active || generation !== nativeSubscriptionGeneration) {
return
}
if (onOverflow) {
onOverflow()
} else if (onWatchError) {
onWatchError(new Error('Git common watcher overflowed'))
} else {
onEvents([{ type: 'update', path: worktreesDir }])
}
}
}
)
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { startGitCommonPolling } from './worktree-git-common-polling'
import type {
WorktreeBasePollEvent,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: measure the fan-out this poller issues per scan (peak concurrent `stat`
// calls, `readdir` call count as a proxy for "a tick ran") without depending on
// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a
// specific pre-existing entry (its dir plus every leaf), used to prove the
// entry-dir signature gate keeps an unchanged entry to one stat per tick.
const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({
statDelayMs: { current: 0 },
readdirCalls: { count: 0 },
concurrency: { current: 0, peak: 0 },
entryZeroStatCalls: { count: 0 }
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
readdir: (...args: Parameters<typeof actual.readdir>) => {
readdirCalls.count += 1
return actual.readdir(...args)
},
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
const path = args[0]
const entryZeroSegment = `${sep}wt-0`
if (
typeof path === 'string' &&
(path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`))
) {
entryZeroStatCalls.count += 1
}
try {
if (statDelayMs.current > 0) {
await new Promise((resolve) => setTimeout(resolve, statDelayMs.current))
}
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
async function makeCommonDir(entryCount: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-'))
for (let i = 0; i < entryCount; i++) {
const entryPath = join(root, 'worktrees', `wt-${i}`)
await mkdir(join(entryPath, 'logs'), { recursive: true })
await Promise.all([
writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'),
writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`),
writeFile(join(entryPath, 'index'), Buffer.from([0])),
writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n')
])
}
return root
}
describe('startGitCommonPolling fan-out bounds (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
const dirsToRemove: string[] = []
beforeEach(() => {
statDelayMs.current = 0
readdirCalls.count = 0
concurrency.current = 0
concurrency.peak = 0
entryZeroStatCalls.count = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
await Promise.all(
dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
vi.useRealTimers()
})
it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => {
const commonDir = await makeCommonDir(200)
dirsToRemove.push(commonDir)
const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// 200 entries x ~6 concurrent structural stats each would peak near 1,200
// unbounded; bounding to 8 in-flight entries keeps the peak independent of
// entry count instead of scaling with it.
expect(concurrency.peak).toBeLessThan(80)
})
it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => {
const commonDir = await makeCommonDir(10)
dirsToRemove.push(commonDir)
statDelayMs.current = 20
const pollIntervalMs = 5
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
readdirCalls.count = 0
// ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms;
// the ticking guard must serialize scans, not launch overlapping ones.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(readdirCalls.count).toBeLessThan(10)
})
it('costs exactly one stat per tick for an unchanged entry', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const pollIntervalMs = 20
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot (which always fully reads every entry once) settle.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
readdirCalls.count = 0
entryZeroStatCalls.count = 0
await vi.waitFor(
() => {
expect(readdirCalls.count).toBeGreaterThanOrEqual(5)
},
{ timeout: 2_000 }
)
// Without the entry-dir signature gate, an unchanged entry still costs ~6
// stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index).
// With the gate, only the entry dir itself is stat'd once nothing changed —
// one stat per tick, in lockstep with the readdir tripwire.
expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1)
expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1)
})
it('detects a HEAD rewrite via lock+rename on the next tick', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 20
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const headPath = join(entryDir, 'HEAD')
const headLockPath = join(entryDir, 'HEAD.lock')
// Every real git ref write goes through a lock file + rename inside the entry
// dir (never an in-place overwrite), which moves the entry dir's own signature.
await writeFile(headLockPath, 'ref: refs/heads/feature\n')
await rename(headLockPath, headPath)
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: headPath })
},
{ timeout: pollIntervalMs * 10 }
)
})
it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 10
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const gitdirPath = join(entryDir, 'gitdir')
// `gitdir` is the one structural leaf git rewrites in place (worktree move/repair),
// so the entry dir's own signature never moves — the periodic ungated backstop
// (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it.
await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`)
// Not caught by the next several ticks: the gate stays closed since nothing
// moved the entry dir's own signature.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5))
expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath })
// Eventually caught regardless of the gate, once tick 15 forces the periodic backstop.
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath })
},
{ timeout: pollIntervalMs * 40 }
)
})
it('still detects entry add/remove correctly with bounded concurrency', async () => {
const commonDir = await makeCommonDir(5)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
20,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
const newEntry = join(commonDir, 'worktrees', 'wt-new')
await mkdir(join(newEntry, 'logs'), { recursive: true })
await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n')
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'create', path: newEntry })
})
await rm(newEntry, { recursive: true })
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry })
})
})
})

Some files were not shown because too many files have changed in this diff Show More