Merge remote-tracking branch 'origin/main' into ra/max-lines-bottom-13-gitlab-item-dialog

# Conflicts:
#	config/max-lines-baseline.txt
#	src/renderer/src/hooks/automation-dispatch-completion.ts
This commit is contained in:
Neil
2026-08-29 20:14:23 -07:00
107 changed files with 6915 additions and 459 deletions
+30 -7
View File
@@ -55,9 +55,13 @@ jobs:
env:
VITE_EXPOSE_STORE: 'true'
run: |
npx electron-vite build --mode e2e
pnpm run build:web-from-renderer
pnpm run build:relay
status=0
pnpm run build:relay &
relay_pid=$!
npx electron-vite build --mode e2e || status=1
pnpm run build:web-from-renderer || status=1
wait "$relay_pid" || status=1
exit "$status"
- name: Upload E2E build output
uses: actions/upload-artifact@v7
@@ -71,9 +75,29 @@ jobs:
retention-days: 1
if-no-files-found: error
# Build Electron-native dependencies once per workflow. Consumer shards restore
# this immutable cache instead of compiling the same ABI concurrently.
prepare-native-cache:
name: prepare Electron native cache
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
needs: [build, prepare-native-cache]
if: inputs.test_files == ''
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -159,7 +183,7 @@ jobs:
changed-e2e:
name: changed e2e specs
needs: build
needs: [build, prepare-native-cache]
if: inputs.test_files != ''
runs-on: ubuntu-latest
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
@@ -227,8 +251,7 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
needs: [build, prepare-native-cache]
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
@@ -8,6 +8,7 @@ const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#build': { contents: 'read' },
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
@@ -46,6 +46,28 @@ describe('release E2E dispatch contract', () => {
expect(refInput.required).toBe(false)
})
it('overlaps the relay bundle with the Electron build', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
expect(buildStep.run).toContain('pnpm run build:relay &')
expect(buildStep.run).toContain('relay_pid=$!')
expect(buildStep.run).toContain('wait "$relay_pid"')
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
})
it('primes the Electron native cache before every E2E consumer', () => {
const primer = e2eWorkflow.jobs['prepare-native-cache']
expect(primer.steps).toBeDefined()
expect(
primer.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with
).toEqual({
'native-runtime': 'electron'
})
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(e2eWorkflow.jobs[jobName].needs, jobName).toEqual(['build', 'prepare-native-cache'])
}
})
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
@@ -71,7 +93,7 @@ describe('release E2E dispatch contract', () => {
const downloadStep = job.steps.find((step) => step.name === 'Download E2E build output')
const runStep = job.steps.find((step) => step.name === runStepName)
expect(job.needs).toBe('build')
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
expect(downloadStep.with.name).toBe('e2e-build-out')
expect(downloadStep.with.path).toBe('out/')
expect(runStep.run).toContain('ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay"')
@@ -285,6 +285,7 @@ describe('openPopupWithOriginBar', () => {
it('closes the window when the popup content is destroyed, without re-closing the contents', () => {
const adopted = createFakeWebContents()
openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/')
const { bar } = lastViews()
adopted.emit('destroyed')
@@ -292,6 +293,7 @@ describe('openPopupWithOriginBar', () => {
// The window's closed handler must not call close() on already-destroyed
// contents — that throws in real Electron.
expect(adopted.close).not.toHaveBeenCalled()
expect(bar.webContents.close).toHaveBeenCalledTimes(1)
})
it('re-asserts the origin when the popup finishes loading', () => {
@@ -326,11 +328,54 @@ describe('openPopupWithOriginBar', () => {
const adopted = createFakeWebContents()
const onClosed = vi.fn()
const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/')
const { bar } = lastViews()
popup.onClosed(onClosed)
popup.close()
expect(adopted.close).toHaveBeenCalledTimes(1)
expect(bar.webContents.close).toHaveBeenCalledTimes(1)
expect(onClosed).toHaveBeenCalledTimes(1)
})
it('releases every origin bar across repeated popup lifecycles', () => {
const cycleCount = 25
const originBars: FakeWebContents[] = []
for (let cycle = 0; cycle < cycleCount; cycle += 1) {
const popup = openPopupWithOriginBar({}, `https://example.com/${cycle}`)
originBars.push(lastViews().bar.webContents)
popup.close()
}
expect(originBars.filter((contents) => contents.close.mock.calls.length === 1)).toHaveLength(
cycleCount
)
})
it('tolerates an origin bar destroyed before its window closes', () => {
const adopted = createFakeWebContents()
const onClosed = vi.fn()
const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/')
popup.onClosed(onClosed)
const { bar } = lastViews()
const barWebContents = bar.webContents
barWebContents.emit('destroyed')
Object.defineProperty(bar, 'webContents', { get: () => undefined })
expect(() => popup.close()).not.toThrow()
expect(adopted.close).toHaveBeenCalledTimes(1)
expect(barWebContents.close).not.toHaveBeenCalled()
expect(onClosed).toHaveBeenCalledTimes(1)
})
it('closes both child contents when popup preparation fails', () => {
const adopted = createFakeWebContents()
openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/', () => false)
expect(adopted.close).toHaveBeenCalledTimes(1)
expect(lastViews().bar.webContents.close).toHaveBeenCalledTimes(1)
expect(lastWindow().close).toHaveBeenCalledTimes(1)
})
})
+14 -5
View File
@@ -92,7 +92,8 @@ function clampPopupContentSize(options: PopupChildWindowOptions): {
// caller's popup bookkeeping cannot leak an entry for a window that no longer exists.
function closeUnpreparedPopup(
window: BaseWindow,
contentWebContents: Electron.WebContents
contentWebContents: Electron.WebContents,
originBarWebContents: Electron.WebContents
): PopupOriginBarWindow {
const closedListeners: (() => void)[] = []
let closed = false
@@ -105,6 +106,9 @@ function closeUnpreparedPopup(
if (!contentWebContents.isDestroyed()) {
contentWebContents.close()
}
if (!originBarWebContents.isDestroyed()) {
originBarWebContents.close()
}
if (!window.isDestroyed()) {
window.close()
}
@@ -159,6 +163,7 @@ export function openPopupWithOriginBar(
const originBarView = new WebContentsView({
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }
})
const originBarWebContents = originBarView.webContents
const contentView = new WebContentsView({
// Why: Electron rejects an explicitly undefined webContents; omitting it
// lets WebContentsView create contents for Cmd/Ctrl-click popups.
@@ -188,7 +193,7 @@ export function openPopupWithOriginBar(
const contentWebContents = contentView.webContents
if (prepareContent && !prepareContent(contentWebContents)) {
return closeUnpreparedPopup(window, contentWebContents)
return closeUnpreparedPopup(window, contentWebContents, originBarWebContents)
}
let currentUrl = initialUrl
const renderOrigin = (): void => {
@@ -202,15 +207,15 @@ export function openPopupWithOriginBar(
}
// Why: textContent + JSON encoding — the URL is attacker-controlled and
// must never be interpolated into the bar's markup.
void originBarView.webContents
void originBarWebContents
.executeJavaScript(
`document.body.classList.toggle('insecure', ${insecure ? 'true' : 'false'});` +
`document.getElementById('origin').textContent = ${JSON.stringify(label)};`
)
.catch(() => {})
}
originBarView.webContents.once('did-finish-load', renderOrigin)
void originBarView.webContents.loadURL(
originBarWebContents.once('did-finish-load', renderOrigin)
void originBarWebContents.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent(ORIGIN_BAR_HTML)}`
)
@@ -253,6 +258,10 @@ export function openPopupWithOriginBar(
// pages often notify the opener from unload.
contentWebContents.close()
}
// Why: BaseWindow does not destroy child WebContentsView contents when it closes.
if (!originBarWebContents.isDestroyed()) {
originBarWebContents.close()
}
for (const listener of closedListeners) {
listener()
}
@@ -13,6 +13,7 @@ import {
function shouldRecordKilledRenderer(expectedTeardown: 'none' | 'renderer-reload' | 'app-shutdown') {
return shouldRecordProcessGoneCrash({
platform: 'win32',
source: 'renderer',
processType: 'renderer',
reason: 'killed',
@@ -66,7 +66,13 @@ const FIELD_GPU_EVENT = {
describe('1.4.190 win32 GPU-child crash cluster', () => {
it('does not let process_gone_suppressed gate the fallback candidate check', () => {
// The GPU death is suppressed as recoverable churn (no user-facing report)...
expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false)
expect(
shouldRecordProcessGoneCrash({
...FIELD_GPU_EVENT,
platform: 'win32',
exitCode: -2147483645
})
).toBe(false)
// ...but the fallback path reads the raw child-process-gone event, so the
// suppression cannot hide a broken driver from recovery.
expect(
@@ -1,9 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
shouldRecordProcessGoneCrash,
shouldRecordProcessGoneCrash as classifyProcessGoneCrash,
shouldRecoverRendererAfterProcessGone
} from './process-gone-classification'
type ProcessGoneClassificationInput = Parameters<typeof classifyProcessGoneCrash>[0]
function shouldRecordProcessGoneCrash({
platform = 'darwin',
...event
}: Omit<ProcessGoneClassificationInput, 'platform'> & {
platform?: NodeJS.Platform
}): boolean {
return classifyProcessGoneCrash({ platform, ...event })
}
describe('shouldRecordProcessGoneCrash', () => {
it('suppresses killed process exits during expected lifecycle teardown', () => {
expect(
@@ -258,15 +269,36 @@ describe('shouldRecordProcessGoneCrash', () => {
).toBe(true)
})
it('still records renderer kills from the recent Linux crash-report cluster', () => {
it('suppresses namespace-encoded SIGTERM kills from the recent Linux cluster', () => {
expect(
shouldRecordProcessGoneCrash({
platform: 'linux',
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 61696,
expectedTeardown: 'none'
})
).toBe(false)
})
it('keeps wait status 61696 reportable outside the exact Linux killed boundary', () => {
const processGone = {
source: 'renderer' as const,
processType: 'renderer',
reason: 'killed',
exitCode: 61696,
expectedTeardown: 'none' as const
}
expect(shouldRecordProcessGoneCrash({ ...processGone, platform: 'darwin' })).toBe(true)
expect(shouldRecordProcessGoneCrash({ ...processGone, platform: 'win32' })).toBe(true)
expect(
shouldRecordProcessGoneCrash({
...processGone,
platform: 'linux',
reason: 'abnormal-exit'
})
).toBe(true)
})
@@ -285,6 +317,7 @@ describe('shouldRecordProcessGoneCrash', () => {
it('records non-SIGTERM killed process exits outside expected lifecycle teardown', () => {
expect(
shouldRecordProcessGoneCrash({
platform: 'linux',
source: 'renderer',
processType: 'renderer',
reason: 'killed',
@@ -339,6 +372,15 @@ describe('shouldRecordProcessGoneCrash', () => {
})
describe('shouldRecoverRendererAfterProcessGone', () => {
it('recovers unexpected killed renderers', () => {
expect(
shouldRecoverRendererAfterProcessGone({
reason: 'killed',
expectedTeardown: 'none'
})
).toBe(true)
})
it('does not recover expected renderer reload teardown', () => {
expect(
shouldRecoverRendererAfterProcessGone({
@@ -2,6 +2,8 @@ export type ProcessGoneSource = 'renderer' | 'child'
export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown'
const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004])
// Chromium's PID-namespace SIGTERM handler exits 241; waitpid reports 241 << 8.
const LINUX_NAMESPACE_SIGTERM_WAIT_STATUS = 0xf100
const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu'])
const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'audio.mojom.AudioService',
@@ -49,6 +51,7 @@ function isRecoverableChromiumChildProcess({
}
export function shouldRecordProcessGoneCrash({
platform,
source,
processType,
serviceName,
@@ -56,6 +59,7 @@ export function shouldRecordProcessGoneCrash({
exitCode,
expectedTeardown
}: {
platform: NodeJS.Platform
source: ProcessGoneSource
processType?: string
serviceName?: string
@@ -76,7 +80,11 @@ export function shouldRecordProcessGoneCrash({
// Why: Electron reports expected Chromium teardown during reload/update as
// `killed` + SIGTERM or Windows control termination statuses. Treat real
// crash reasons as reportable, but skip these normal termination shapes.
if (exitCode === 15 || isWindowsControlTerminationExitCode(exitCode)) {
if (
exitCode === 15 ||
isWindowsControlTerminationExitCode(exitCode) ||
(platform === 'linux' && exitCode === LINUX_NAMESPACE_SIGTERM_WAIT_STATUS)
) {
return false
}
if (expectedTeardown === 'app-shutdown') {
@@ -117,6 +117,41 @@ describe('recordProcessGoneCrash', () => {
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('durably suppresses a Linux namespace-encoded renderer SIGTERM', () => {
const record = vi.fn()
withStubbedPlatform('linux', () => {
recordProcessGoneCrash(
{ record } as never,
event({ reason: 'killed', exitCode: 61696 }),
new ProcessGoneDedupe()
)
})
expect(record).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'process_gone_suppressed',
data: expect.objectContaining({
source: 'renderer',
reason: 'killed',
exitCode: 61696,
expectedTeardown: 'none'
})
})
])
expect(sink.records).toEqual([
expect.objectContaining({
name: 'crash.breadcrumb',
attributes: expect.objectContaining({
'breadcrumb.name': 'process_gone_suppressed',
'breadcrumb.data': expect.objectContaining({ exitCode: 61696 })
})
})
])
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('keeps suppressed renderer evidence scoped to its renderer', () => {
const dedupe = new ProcessGoneDedupe()
const suppressed = (webContentsId: number) =>
@@ -475,10 +510,10 @@ describe('recordProcessGoneCrash', () => {
}
}
// Why child kills: the decode gate is source-agnostic and reads
// Why child exits: the decode gate is source-agnostic and reads
// process.platform synchronously at record time, so the platform stub is
// still in force when it runs.
const nonRecoverableChildKill = (
const nonRecoverableChildExit = (
overrides: Partial<ProcessGoneCrashEvent>
): ProcessGoneCrashEvent =>
event({
@@ -494,7 +529,7 @@ describe('recordProcessGoneCrash', () => {
withStubbedPlatform('linux', () => {
recordProcessGoneCrash(
{ record } as never,
nonRecoverableChildKill({ reason: 'killed', exitCode: 61696 }),
nonRecoverableChildExit({ reason: 'abnormal-exit', exitCode: 61696 }),
new ProcessGoneDedupe()
)
})
@@ -518,14 +553,14 @@ describe('recordProcessGoneCrash', () => {
withStubbedPlatform('win32', () => {
recordProcessGoneCrash(
{ record } as never,
nonRecoverableChildKill({ reason: 'killed', exitCode: 1 }),
nonRecoverableChildExit({ reason: 'killed', exitCode: 1 }),
new ProcessGoneDedupe()
)
})
withStubbedPlatform('linux', () => {
recordProcessGoneCrash(
{ record } as never,
nonRecoverableChildKill({ reason: 'launch-failed', exitCode: 18 }),
nonRecoverableChildExit({ reason: 'launch-failed', exitCode: 18 }),
new ProcessGoneDedupe()
)
})
@@ -80,10 +80,6 @@ const captureProcessMinidump: MinidumpCapture = (crashedAtMs, expectedProcessTyp
// one here would weaken the other 30s coalescers. Stay uniform with them.
const SUPPRESSED_PROCESS_GONE_COALESCE_MS = 30_000
function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) {
return buildSuppressedProcessGoneBreadcrumbData(event)
}
function processGoneRendererOrigin(event: ProcessGoneCrashEvent): string | undefined {
return event.webContentsId === undefined
? undefined
@@ -122,7 +118,7 @@ function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) {
? error.code
: undefined
return {
...processGoneBreadcrumbData(event),
...buildSuppressedProcessGoneBreadcrumbData(event),
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)),
...(errorCode ? { errorCode } : {})
@@ -194,6 +190,7 @@ export function recordProcessGoneCrash(
scheduleCrashpadDumpPrune()
if (
!shouldRecordProcessGoneCrash({
platform: process.platform,
source: event.source,
processType: event.processType,
serviceName,
@@ -205,7 +202,7 @@ export function recordProcessGoneCrash(
// Why: Chromium can crash-loop a recoverable child (network service seen at
// 1459/min) and each suppressed event costs a span plus a forced disk flush,
// which both floods the 30-entry ring and evicts the real pre-crash trail.
const suppressedData = processGoneBreadcrumbData(event)
const suppressedData = buildSuppressedProcessGoneBreadcrumbData(event)
const origin = processGoneRendererOrigin(event)
recordCoalescedDurableCrashBreadcrumb({
name: 'process_gone_suppressed',
@@ -221,7 +218,7 @@ export function recordProcessGoneCrash(
if (!store) {
recordDurableCrashBreadcrumb(
'crash_report_store_unavailable',
processGoneBreadcrumbData(event),
buildSuppressedProcessGoneBreadcrumbData(event),
'Crash report store unavailable',
processGoneRendererOrigin(event)
)
@@ -305,7 +302,7 @@ export function recordProcessGoneCrash(
siblingDeaths,
(reportId, details) => store.attachDetails(reportId, details),
recorded,
processGoneBreadcrumbData(event),
buildSuppressedProcessGoneBreadcrumbData(event),
processGoneRendererOrigin(event)
)
void recorded
@@ -322,7 +319,7 @@ export function recordProcessGoneCrash(
console.error('[crash-reporting] Failed to attach minidump signature:', error)
recordDurableCrashBreadcrumb(
'minidump_signature_attach_failed',
processGoneBreadcrumbData(event),
buildSuppressedProcessGoneBreadcrumbData(event),
error instanceof Error ? error.message : String(error),
processGoneRendererOrigin(event)
)
@@ -30,6 +30,7 @@ import {
_getWorktreeScanCacheSizesForTests,
_resetWorktreeScanCacheForTests,
listWorktrees,
listWorktreesStrict,
moveWorktree,
removeWorktree,
WORKTREE_LIST_TIMEOUT_MS
@@ -120,6 +121,19 @@ describe('listWorktrees in-flight sharing', () => {
}
})
it('distinguishes fail-soft empty results from strict scan failures', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const failure = new Error('git spawn timed out')
gitExecFileAsyncMock.mockRejectedValue(failure)
await expect(listWorktrees('/repo')).resolves.toEqual([])
await expect(listWorktreesStrict('/repo')).rejects.toBe(failure)
} finally {
warnSpy.mockRestore()
}
})
it('does not share scans across different repos', async () => {
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
Promise.resolve({
+3 -2
View File
@@ -3389,12 +3389,12 @@ void app.whenReady().then(async () => {
let desktopWindow: BrowserWindow | null = null
if (process.platform === 'win32' && app.isPackaged && !serveOptions) {
const desktopStartup = startWindowsDesktopBeforeShellPathReady({
bindServices: bindTerminalRuntimeStartupServices,
openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }),
shellPathReady,
startServices: startTerminalRuntimeStartupServices
})
desktopWindow = desktopStartup.window
bindTerminalRuntimeStartupServices(desktopStartup.services)
} else {
await shellPathReady
bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))
@@ -3410,7 +3410,8 @@ void app.whenReady().then(async () => {
})
// Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers.
await localPtyStartupReady
registerHeadlessPtyRuntime(
await localPtyProviderStartupReady
await registerHeadlessPtyRuntime(
runtime,
prepareCodexRuntimeHomeForLaunch,
() => store!.getSettings(),
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'
const { hydrateMock, registerHandlersMock } = vi.hoisted(() => ({
hydrateMock: vi.fn(),
registerHandlersMock: vi.fn()
}))
vi.mock('./register-handlers', () => ({
registerPtyHandlers: registerHandlersMock
}))
vi.mock('../../memory/hydrate-local-pty-registry', () => ({
hydrateLocalPtyRegistryAtBoot: hydrateMock
}))
import { registerHeadlessPtyRuntime } from './register-headless-runtime'
describe('registerHeadlessPtyRuntime', () => {
it('registers handlers before awaiting registry hydration', async () => {
let resolveHydration!: () => void
const hydration = new Promise<void>((resolve) => {
resolveHydration = resolve
})
const events: string[] = []
registerHandlersMock.mockImplementation(() => events.push('handlers'))
hydrateMock.mockImplementation(() => {
events.push('hydrate')
return hydration
})
const store = {} as never
const ready = registerHeadlessPtyRuntime({} as never, undefined, undefined, undefined, store)
expect(events).toEqual(['handlers', 'hydrate'])
expect(registerHandlersMock).toHaveBeenCalledOnce()
expect(hydrateMock).toHaveBeenCalledWith(store)
resolveHydration()
await ready
})
})
@@ -9,6 +9,7 @@ import type {
PrepareCodexSessionResume
} from './host-env/types'
import { registerPtyHandlers } from './register-handlers'
import { hydrateLocalPtyRegistryAtBoot } from '../../memory/hydrate-local-pty-registry'
export function registerHeadlessPtyRuntime(
runtime: OrcaRuntimeService,
@@ -21,7 +22,7 @@ export function registerHeadlessPtyRuntime(
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
onPtyExit?: (id: string, exitSequence: number) => void
}
): void {
): Promise<void> {
// Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals.
// Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true`
// is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted.
@@ -44,4 +45,5 @@ export function registerHeadlessPtyRuntime(
store,
{ prepareCodexSessionResume, ...lifecycle }
)
return store ? hydrateLocalPtyRegistryAtBoot(store) : Promise.resolve()
}
@@ -7,6 +7,9 @@ import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-co
const registeredWorktreeRoots = new Set<string>()
const registeredWorktreeRootsByRepo = new Map<string, Set<string>>()
const registeredWorktreeRootRepoIds = new Set<string>()
const registeredWorktreeRootsRevisionByRepo = new Map<string, number>()
let registeredWorktreeRootsRevisionSequence = 0
let registeredWorktreeRootsBaseRevision = 0
let registeredWorktreeRootsDirty = true
let registeredWorktreeRootsRefresh: Promise<void> | null = null
const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
@@ -17,6 +20,8 @@ export function invalidateAuthorizedRootsCache(): void {
registeredWorktreeRoots.clear()
registeredWorktreeRootsByRepo.clear()
registeredWorktreeRootRepoIds.clear()
registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence
registeredWorktreeRootsRevisionByRepo.clear()
}
export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
@@ -55,6 +60,8 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
registeredWorktreeRootRepoIds.add(repoId)
}
registeredWorktreeRootsDirty = false
registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence
registeredWorktreeRootsRevisionByRepo.clear()
}
async function mapWithConcurrency<T, R>(
@@ -87,6 +94,10 @@ export function registerWorktreeRootsForRepo(
if (!localRepoIds.has(registeredRepoId)) {
registeredWorktreeRootsByRepo.delete(registeredRepoId)
registeredWorktreeRootRepoIds.delete(registeredRepoId)
registeredWorktreeRootsRevisionByRepo.set(
registeredRepoId,
++registeredWorktreeRootsRevisionSequence
)
}
}
@@ -98,10 +109,15 @@ export function registerWorktreeRootsForRepo(
registeredWorktreeRootsByRepo.set(repoId, new Set(worktreeRoots.map((root) => resolve(root))))
registeredWorktreeRootRepoIds.add(repoId)
registeredWorktreeRootsRevisionByRepo.set(repoId, ++registeredWorktreeRootsRevisionSequence)
refreshRegisteredWorktreeRoots()
registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds)
}
export function getRegisteredWorktreeRootsRevision(repoId: string): number {
return registeredWorktreeRootsRevisionByRepo.get(repoId) ?? registeredWorktreeRootsBaseRevision
}
export async function ensureAuthorizedRootsCache(store: Store): Promise<void> {
if (!registeredWorktreeRootsDirty) {
return
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join, sep } from 'node:path'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
const { getSshFilesystemProviderMock, readFileMock, realpathMock, statMock } = vi.hoisted(() => ({
getSshFilesystemProviderMock: vi.fn(),
readFileMock: vi.fn(),
realpathMock: vi.fn(),
statMock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({
readFile: readFileMock,
realpath: realpathMock,
stat: statMock
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
}))
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings,
WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY
} from './worktree-base-directory-watch-targets'
const absolutePath = (...parts: string[]): string => join(sep, ...parts)
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
const PROJECT_ROOT = absolutePath('workspace', 'projects')
const localDirectoryStat = { isDirectory: () => true }
const remoteDirectoryStat = { type: 'directory', size: 0, mtime: 0 }
const settings = {
workspaceDir: WORKTREE_ROOT,
nestWorkspaces: true
} as GlobalSettings
function makeRepo(index: number, overrides: Partial<Repo> = {}): Repo {
return {
id: `repo-${index}`,
path: join(PROJECT_ROOT, `project-${index}`),
displayName: `Project ${index}`,
badgeColor: '#000000',
addedAt: index,
...overrides
} as Repo
}
function makeStore(repos: Repo[]) {
return {
getSettings: () => settings,
getRepos: () => repos
}
}
function makeRemoteProvider() {
return {
stat: vi.fn(async () => remoteDirectoryStat),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false }))
}
}
describe('worktree base directory watch target resolution', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
statMock.mockReset().mockResolvedValue(localDirectoryStat)
realpathMock.mockReset().mockImplementation(async (path: string) => path)
readFileMock.mockReset().mockResolvedValue('')
getSshFilesystemProviderMock.mockReset().mockReturnValue(undefined)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
clearWorktreeBaseDirectoryWatchTargetWarnings()
warnSpy.mockRestore()
})
it('bounds resolution concurrency and starts one successor per released slot', async () => {
const repos = Array.from(
{ length: WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY + 4 },
(_, index) => makeRepo(index)
)
const gates = repos.map(() => Promise.withResolvers<void>())
let startedRootStats = 0
let activeRootStats = 0
let peakRootStats = 0
statMock.mockImplementation(async (path: string) => {
if (path === WORKTREE_ROOT) {
const gate = gates[startedRootStats++]
activeRootStats++
peakRootStats = Math.max(peakRootStats, activeRootStats)
await gate.promise
activeRootStats--
}
return localDirectoryStat
})
const resultPromise = buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never)
await vi.waitFor(() => {
expect(startedRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY)
})
expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY)
gates[0].resolve()
await vi.waitFor(() => {
expect(startedRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY + 1)
})
expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY)
for (const gate of gates) {
gate.resolve()
}
await resultPromise
expect(startedRootStats).toBe(repos.length)
expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY)
})
it('merges targets and shared repo membership in store order after reverse completion', async () => {
const repos = Array.from({ length: 4 }, (_, index) => makeRepo(index))
const gates = repos.map(() => Promise.withResolvers<void>())
const gitStatStarted = new Set<number>()
const completionOrder: number[] = []
statMock.mockImplementation(async (path: string) => {
const index = repos.findIndex((repo) => path === join(repo.path, '.git'))
if (index !== -1) {
gitStatStarted.add(index)
await gates[index].promise
completionOrder.push(index)
}
return localDirectoryStat
})
const resultPromise = buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never)
await vi.waitFor(() => expect(gitStatStarted.size).toBe(repos.length))
for (let index = repos.length - 1; index >= 0; index--) {
gates[index].resolve()
await vi.waitFor(() => expect(completionOrder).toContain(index))
}
const targets = await resultPromise
expect(completionOrder).toEqual([3, 2, 1, 0])
expect([...targets.values()].map((target) => [target.kind, target.path])).toEqual([
['base', WORKTREE_ROOT],
...repos.map((repo) => ['git-common', join(repo.path, '.git')])
])
expect([...([...targets.values()][0]?.repos.keys() ?? [])]).toEqual(
repos.map((repo) => repo.id)
)
})
it('isolates missing paths to the affected repo', async () => {
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
const goodBefore = makeRepo(0, { worktreeBasePath: absolutePath('worktrees', 'before') })
const unavailable = makeRepo(1, { worktreeBasePath: absolutePath('worktrees', 'missing') })
const goodAfter = makeRepo(2, { worktreeBasePath: absolutePath('worktrees', 'after') })
statMock.mockImplementation(async (path: string) => {
if (path === unavailable.worktreeBasePath || path === join(unavailable.path, '.git')) {
throw missing
}
return localDirectoryStat
})
const targets = await buildWorktreeBaseDirectoryWatchTargets(
makeStore([goodBefore, unavailable, goodAfter]) as never
)
const repoIds = [...targets.values()].flatMap((target) => [...target.repos.keys()])
expect(repoIds).toContain(goodBefore.id)
expect(repoIds).not.toContain(unavailable.id)
expect(repoIds).toContain(goodAfter.id)
})
it('keeps identical SSH paths isolated by connection and provider', async () => {
const providerA = makeRemoteProvider()
const providerB = makeRemoteProvider()
getSshFilesystemProviderMock.mockImplementation((connectionId: string) =>
connectionId === 'ssh-a' ? providerA : connectionId === 'ssh-b' ? providerB : undefined
)
const sharedPath = '/srv/project'
const repos = [
makeRepo(0, { path: sharedPath, connectionId: 'ssh-a' }),
makeRepo(1, { path: sharedPath, connectionId: 'ssh-b' })
]
const targets = await buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never)
expect(providerA.stat).toHaveBeenCalledTimes(2)
expect(providerA.realpath).toHaveBeenCalledTimes(2)
expect(providerB.stat).toHaveBeenCalledTimes(2)
expect(providerB.realpath).toHaveBeenCalledTimes(2)
expect([...targets.values()].map((target) => target.connectionId)).toEqual([
'ssh-a',
'ssh-a',
'ssh-b',
'ssh-b'
])
expect(new Set(targets.keys()).size).toBe(4)
})
it('skips folder workspaces, unavailable SSH providers, and WSL UNC roots', async () => {
const folder = makeRepo(0, { kind: 'folder' })
const unavailableSsh = makeRepo(1, { connectionId: 'missing', path: '/srv/missing' })
const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\project'
const wsl = makeRepo(2, { path: wslPath, worktreeBasePath: wslPath })
const targets = await buildWorktreeBaseDirectoryWatchTargets(
makeStore([folder, unavailableSsh, wsl]) as never
)
expect(targets.size).toBe(0)
expect(statMock).not.toHaveBeenCalled()
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('missing')
})
it('does not publish ordered partial targets when a repo resolver rejects unexpectedly', async () => {
const completed = makeRepo(0)
const broken = makeRepo(1)
Object.defineProperty(broken, 'path', {
get: () => {
throw new Error('unexpected resolver failure')
}
})
await expect(
buildWorktreeBaseDirectoryWatchTargets(makeStore([completed, broken]) as never)
).rejects.toThrow('unexpected resolver failure')
expect(statMock).toHaveBeenCalled()
})
})
@@ -15,6 +15,7 @@ import {
resolveRuntimePath
} from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
computeWorkspaceRoot,
@@ -32,6 +33,9 @@ import type {
const missingRootWarnings = new Set<string>()
const skippedWslWarnings = new Set<string>()
// Why: match existing worktree probe caps while bounding aggregate SSH filesystem RPC pressure.
export const WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY = 8
function normalizeWatchKey(pathValue: string): string {
return normalizeRuntimePathForComparison(normalize(pathValue))
}
@@ -177,21 +181,51 @@ async function maybeAddBaseTarget(
}
}
async function resolveRepoTargets(
repo: Repo,
settings: GlobalSettings
): Promise<Map<string, WorktreeBaseWatchTarget>> {
const targets = new Map<string, WorktreeBaseWatchTarget>()
if (isFolderRepo(repo)) {
return targets
}
const executionHostId = getRepoExecutionHostId(repo)
if (executionHostId === LOCAL_EXECUTION_HOST_ID) {
await maybeAddBaseTarget(targets, repo, settings)
} else if (repo.connectionId) {
await maybeAddBaseTarget(targets, repo, settings, repo.connectionId)
}
return targets
}
function mergeRepoTargets(
targets: Map<string, WorktreeBaseWatchTarget>,
repoTargets: Map<string, WorktreeBaseWatchTarget>
): void {
for (const [key, target] of repoTargets) {
const existing = targets.get(key)
if (!existing) {
targets.set(key, target)
continue
}
for (const [repoId, config] of target.repos) {
existing.repos.set(repoId, config)
}
}
}
export async function buildWorktreeBaseDirectoryWatchTargets(
store: Store
): Promise<Map<string, WorktreeBaseWatchTarget>> {
const settings = store.getSettings()
const resolvedRepoTargets = await mapWithConcurrency(
store.getRepos(),
WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY,
(repo) => resolveRepoTargets(repo, settings)
)
const targets = new Map<string, WorktreeBaseWatchTarget>()
for (const repo of store.getRepos()) {
if (isFolderRepo(repo)) {
continue
}
const executionHostId = getRepoExecutionHostId(repo)
if (executionHostId === LOCAL_EXECUTION_HOST_ID) {
await maybeAddBaseTarget(targets, repo, settings)
} else if (repo.connectionId) {
await maybeAddBaseTarget(targets, repo, settings, repo.connectionId)
}
for (const repoTargets of resolvedRepoTargets) {
mergeRepoTargets(targets, repoTargets)
}
return targets
}
@@ -0,0 +1,611 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import { notifyWorktreesChanged } from './worktree-remote'
import {
listWorktreesMock,
pruneCleanupScanSnapshotsMock,
pruneSpaceAnalysisSnapshotsMock
} from './worktrees-test-module-mocks'
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockSelectedWslProjectRuntime } from './worktrees-test-fixtures'
import { pruneMetadataMissingFromAuthoritativeLocalScan } from './worktrees/listing/authoritative-local-worktree-metadata-pruning'
import { listDetectedWorktreesForCapturedRepo } from './worktrees/listing/detected-provider-listing'
import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation'
import {
isRegisteredWorktreePath,
registerWorktreeRootsForRepo
} from './registered-worktree-roots-cache'
const localWorktreePathPresenceMock = vi.hoisted(() => vi.fn())
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-git-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
vi.mock('../ssh/ssh-target-registry', async () =>
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../local-worktree-path-presence', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
localWorktreePathsExistOrAreUnverifiable: localWorktreePathPresenceMock
}))
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
const REPO_ID = 'repo-1'
const REPO_PATH = '/workspace/repo'
const LOCAL_HOST_ID = 'local'
function worktree(path: string, overrides: Partial<GitWorktreeInfo> = {}): GitWorktreeInfo {
return {
path,
head: path,
branch: `refs/heads/${path.split('/').at(-1) ?? 'main'}`,
isBare: false,
isMainWorktree: path === REPO_PATH,
...overrides
}
}
function scanExpectation(
worktreeIds: readonly string[]
): NativeLocalWorktreeMetadataScanExpectation {
const expectedRepo = {
id: REPO_ID,
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
return {
repo: {
id: REPO_ID,
path: REPO_PATH,
kind: 'git',
expectedRepo
},
routing: {
expectedProject: undefined,
expectedProjectUpdatedAt: undefined,
expectedSettings:
{} as NativeLocalWorktreeMetadataScanExpectation['routing']['expectedSettings']
},
metadata: worktreeIds.map((worktreeId) => ({
worktreeId,
expectedLegacy: {
expectedPresent: true,
expectedMeta: undefined,
expectedSerialized: undefined,
expectedInstanceId: undefined,
expectedHostId: LOCAL_HOST_ID
},
expectedAliases: []
}))
}
}
function listDetected(): Promise<unknown> {
return handlers['worktrees:listDetected'](null, { repoId: REPO_ID }) as Promise<unknown>
}
const localListingCalls = [
['worktrees:listDetected', listDetected],
[
'worktrees:list',
() => handlers['worktrees:list'](null, { repoId: REPO_ID }) as Promise<unknown>
],
['worktrees:listAll', () => handlers['worktrees:listAll'](null, undefined) as Promise<unknown>]
] as const
describe('authoritative local worktree metadata pruning integration', () => {
beforeEach(() => {
setupWorktreeHandlers()
localWorktreePathPresenceMock.mockReset()
localWorktreePathPresenceMock.mockImplementation(
async (pathValues: readonly string[]) =>
new Map(pathValues.map((pathValue) => [pathValue, false]))
)
})
it('captures metadata expectations before starting the Git scan', async () => {
const order: string[] = []
store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation(() => {
order.push('capture')
return scanExpectation([])
})
listWorktreesMock.mockImplementation(() => {
order.push('scan')
return Promise.resolve([worktree(REPO_PATH)])
})
await listDetected()
expect(order).toEqual(['capture', 'scan'])
})
it('prunes once for the fresh producer, while coalesced and cached callers do nothing', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
const rows = [worktree(REPO_PATH), worktree('/workspace/live')]
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockImplementation(async () => {
await Promise.resolve()
return rows
})
await Promise.all([listDetected(), listDetected(), listDetected()])
await listDetected()
expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1)
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1)
const firstPruneCall = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock
.calls[0] as [unknown, readonly { worktreeId: string }[]] | undefined
expect(firstPruneCall?.[1].map(({ worktreeId }) => worktreeId)).toEqual([staleId])
expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledTimes(1)
expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledWith('/profile-a', [
{ worktreeId: staleId, executionHostId: LOCAL_HOST_ID }
])
expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledTimes(1)
expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledWith('/profile-a', [
{ worktreeId: staleId, executionHostId: LOCAL_HOST_ID }
])
})
it('does not prune when the authoritative scan is invalidated while in flight', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {}
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (rows: GitWorktreeInfo[]) => void
})
)
const pending = listDetected()
await Promise.resolve()
notifyWorktreesChanged(mainWindow as never, REPO_ID)
resolveScan([worktree(REPO_PATH), worktree('/workspace/live')])
await pending
expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1)
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled()
expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled()
})
it('does not prune when the completed scan is invalidated before its caller resumes', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {}
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (rows: GitWorktreeInfo[]) => void
})
)
const pending = listDetected()
await Promise.resolve()
resolveScan([worktree(REPO_PATH)])
queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID))
await pending
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
})
it.each(localListingCalls)(
'skips stale WSL root and lineage side effects after %s caller resumption',
async (_channel, listWorktrees) => {
const orphanId = `${REPO_ID}::/workspace/orphan`
let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {}
mockSelectedWslProjectRuntime()
store.getAllWorktreeLineage.mockReturnValue({
[orphanId]: {
worktreeId: orphanId,
worktreeInstanceId: 'orphan-instance',
parentWorktreeId: `${REPO_ID}::${REPO_PATH}`,
parentWorktreeInstanceId: 'main-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
})
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (rows: GitWorktreeInfo[]) => void
})
)
const pending = listWorktrees()
await Promise.resolve()
resolveScan([worktree(REPO_PATH)])
queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID))
await pending
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
}
)
it('preserves WSL roots and lineage created while an older Git scan is pending', async () => {
const newPath = '/workspace/new-wsl-worktree'
const newId = `${REPO_ID}::${newPath}`
let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {}
mockSelectedWslProjectRuntime()
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (rows: GitWorktreeInfo[]) => void
})
)
const pending = listDetected()
await Promise.resolve()
resolveScan([worktree(REPO_PATH)])
queueMicrotask(() => {
registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath])
store.setWorktreeMeta(newId, { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' })
store.getAllWorktreeLineage.mockReturnValue({
[newId]: {
worktreeId: newId,
worktreeInstanceId: 'new-instance',
parentWorktreeId: `${REPO_ID}::${REPO_PATH}`,
parentWorktreeInstanceId: 'main-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
})
})
await pending
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(isRegisteredWorktreePath(newPath)).toBe(true)
})
it.each(['scan generation', 'caller request'] as const)(
'skips metadata, root, and lineage mutations when the %s is invalidated during path probes',
async (invalidation) => {
const staleId = `${REPO_ID}::/workspace/stale`
const orphanId = `${REPO_ID}::/workspace/orphan`
let callerCurrent = true
let resolvePresence: (presence: ReadonlyMap<string, boolean>) => void = () => {}
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
store.getAllWorktreeLineage.mockReturnValue({
[orphanId]: {
worktreeId: orphanId,
worktreeInstanceId: 'orphan-instance',
parentWorktreeId: `${REPO_ID}::${REPO_PATH}`,
parentWorktreeInstanceId: 'main-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
})
localWorktreePathPresenceMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePresence = resolve
})
)
listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)])
const pending = listDetectedWorktreesForCapturedRepo(
store as never,
store.getRepo(REPO_ID) as never,
() => callerCurrent
)
await vi.waitFor(() => expect(localWorktreePathPresenceMock).toHaveBeenCalledTimes(1))
if (invalidation === 'scan generation') {
notifyWorktreesChanged(mainWindow as never, REPO_ID)
} else {
callerCurrent = false
}
resolvePresence(new Map([['/workspace/stale', false]]))
await pending
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled()
expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled()
}
)
it('preserves roots and lineage created while an older path probe is pending', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
const newPath = '/workspace/new-worktree'
const newId = `${REPO_ID}::${newPath}`
let resolvePresence: (presence: ReadonlyMap<string, boolean>) => void = () => {}
const metadata = new Map<string, { hostId: string; instanceId: string }>()
store.getWorktreeMeta.mockImplementation((worktreeId) => metadata.get(worktreeId))
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
localWorktreePathPresenceMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePresence = resolve
})
)
listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)])
const pending = listDetected()
await vi.waitFor(() => expect(localWorktreePathPresenceMock).toHaveBeenCalledTimes(1))
registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath])
const newMetadata = { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' }
metadata.set(newId, newMetadata)
store.setWorktreeMeta(newId, newMetadata)
store.getAllWorktreeLineage.mockReturnValue({
[newId]: {
worktreeId: newId,
worktreeInstanceId: newMetadata.instanceId,
parentWorktreeId: `${REPO_ID}::${REPO_PATH}`,
parentWorktreeInstanceId: 'main-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
})
resolvePresence(new Map([['/workspace/stale', false]]))
await pending
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1)
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(isRegisteredWorktreePath(newPath)).toBe(true)
})
it('does not capture or prune on an initial WSL scan', async () => {
mockSelectedWslProjectRuntime()
listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)])
await listDetected()
expect(listWorktreesMock).toHaveBeenCalledWith(REPO_PATH, { wslDistro: 'Ubuntu' })
expect(store.captureNativeLocalWorktreeMetadataScanExpectation).not.toHaveBeenCalled()
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
})
it('does not let a native scan that changes to WSL routing become destructive', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
const resolvers: ((rows: GitWorktreeInfo[]) => void)[] = []
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve as (rows: GitWorktreeInfo[]) => void)
})
)
const nativePending = listDetected()
await Promise.resolve()
mockSelectedWslProjectRuntime()
const wslPending = listDetected()
await Promise.resolve()
resolvers[1]?.([worktree(REPO_PATH)])
await wslPending
resolvers[0]?.([worktree(REPO_PATH), worktree('/workspace/live')])
await nativePending
expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1)
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled()
expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled()
})
it.each([
['empty', [] as GitWorktreeInfo[]],
['failed', new Error('git worktree list failed')]
] as const)('does nothing for an %s authoritative scan', async (_label, result) => {
const staleId = `${REPO_ID}::/workspace/stale`
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
if (result instanceof Error) {
listWorktreesMock.mockRejectedValue(result)
} else {
listWorktreesMock.mockResolvedValue(result)
}
await listDetected()
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled()
expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled()
expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled()
})
it('keeps prunable Git registrations live and does not probe their paths', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
const liveId = `${REPO_ID}::/workspace/live`
const prunableId = `${REPO_ID}::/workspace/prunable`
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId, liveId, prunableId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockResolvedValue([
worktree(REPO_PATH),
worktree('/workspace/live'),
worktree('/workspace/prunable', { prunable: true })
])
await listDetected()
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1)
const missing = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock.calls[0]?.[1] as
| readonly { worktreeId: string }[]
| undefined
expect(missing?.map(({ worktreeId }) => worktreeId)).toEqual([staleId])
expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(liveId)
expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(prunableId)
expect(localWorktreePathPresenceMock).toHaveBeenCalledWith(['/workspace/stale'], {
signal: undefined
})
})
it('keeps the configured repo path live when Git reports a canonical spelling', async () => {
const configuredMainId = `${REPO_ID}::${REPO_PATH}`
const staleId = `${REPO_ID}::/workspace/stale`
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([configuredMainId, staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
listWorktreesMock.mockResolvedValue([worktree('/canonical/workspace/repo')])
await listDetected()
const missing = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock.calls[0]?.[1] as
| readonly { worktreeId: string }[]
| undefined
expect(missing?.map(({ worktreeId }) => worktreeId)).toEqual([staleId])
expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(configuredMainId)
})
it('keeps a linked worktree whose persisted symlink spelling still exists', async () => {
const aliasId = `${REPO_ID}::/workspace/alias/linked`
const staleId = `${REPO_ID}::/workspace/stale`
const scan = scanExpectation([aliasId, staleId])
const repo = scan.repo.expectedRepo!
const prune = vi.fn(
(
_scan: NativeLocalWorktreeMetadataScanExpectation,
_missing: readonly { worktreeId: string }[]
) => [] as string[]
)
await pruneMetadataMissingFromAuthoritativeLocalScan({
store: {
getRepos: () => [repo],
pruneSessionlessMissingLocalWorktreeMetadataForRepo: prune
} as never,
repo,
gitWorktrees: [worktree('/workspace/real/linked')],
scan,
scanGeneration: getLocalWorktreeScanGeneration(REPO_ID),
pathsExistOrAreUnverifiable: async (pathValues) =>
new Map(pathValues.map((pathValue) => [pathValue, pathValue === '/workspace/alias/linked']))
})
expect(prune.mock.calls[0]?.[1].map(({ worktreeId }) => worktreeId)).toEqual([staleId])
})
it('does not rematerialize a removed lineage parent metadata row', async () => {
const staleId = `${REPO_ID}::/workspace/stale`
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue(
scanExpectation([staleId])
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId])
store.getAllWorktreeLineage.mockReturnValue({
[`${REPO_ID}::/workspace/live-child`]: {
worktreeId: `${REPO_ID}::/workspace/live-child`,
worktreeInstanceId: 'child-instance',
parentWorktreeId: staleId,
parentWorktreeInstanceId: 'stale-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 0
}
})
listWorktreesMock.mockResolvedValue([worktree(REPO_PATH), worktree('/workspace/live')])
await listDetected()
expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1)
expect(store.setWorktreeMeta.mock.calls.some(([worktreeId]) => worktreeId === staleId)).toBe(
false
)
})
})
+7 -2
View File
@@ -7,6 +7,7 @@ import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retir
import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority'
import { createWorktreeRuntimeStub, type WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
import { handlers, mainWindow, store } from './worktrees-test-ipc-surface'
import { configureMetadataPruningStoreMocks } from './worktrees-test-metadata-pruning-store'
import {
ORIGINAL_PLATFORM,
setPlatform,
@@ -129,10 +130,12 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
store.getWorktreeMeta,
store.getWorktreeMetaForHost,
store.getAllWorktreeMeta,
store.captureNativeLocalWorktreeMetadataScanExpectation,
store.setWorktreeMeta,
store.setWorktreeMetaForHost,
store.getProjectHostSetups,
store.removeWorktreeMeta,
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo,
store.removeWorkspaceSessionStateForWorktree,
store.getAllWorktreeLineage,
store.removeWorktreeLineage,
@@ -187,12 +190,13 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
store.getProjects.mockReturnValue([])
store.getSparsePresets.mockReturnValue([])
store.getSettings.mockReturnValue({
const settings = {
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
workspaceDir: '/workspace'
})
}
store.getSettings.mockReturnValue(settings)
store.getWorktreeMeta.mockReturnValue(undefined)
// Host-qualified accessors delegate by default so payload assertions stay on one spy;
// host routing itself is covered by worktree-identity-persistence.test.ts.
@@ -200,6 +204,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub {
store.getWorktreeMeta(args[0] as string)
)
store.getAllWorktreeMeta.mockReturnValue({})
configureMetadataPruningStoreMocks(store, settings)
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
resetRetirementCollisionKeyCacheForTests()
store.setWorktreeMeta.mockReturnValue({})
@@ -24,10 +24,12 @@ export type TestStore = {
getWorktreeMeta: KeyedStoreMock
getWorktreeMetaForHost: StoreMock
getAllWorktreeMeta: StoreMock
captureNativeLocalWorktreeMetadataScanExpectation: StoreMock
setWorktreeMeta: KeyedStoreWriteMock
setWorktreeMetaForHost: StoreMock
getProjectHostSetups: StoreMock
removeWorktreeMeta: KeyedStoreMock
pruneSessionlessMissingLocalWorktreeMetadataForRepo: StoreMock
removeWorkspaceSessionStateForWorktree: KeyedStoreMock
getAllWorktreeLineage: StoreMock
removeWorktreeLineage: KeyedStoreMock
@@ -58,10 +60,12 @@ export const store: TestStore = {
getWorktreeMeta: vi.fn(),
getWorktreeMetaForHost: vi.fn(),
getAllWorktreeMeta: vi.fn(),
captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(),
setWorktreeMeta: vi.fn(),
setWorktreeMetaForHost: vi.fn(),
getProjectHostSetups: vi.fn(),
removeWorktreeMeta: vi.fn(),
pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(),
removeWorkspaceSessionStateForWorktree: vi.fn(),
getAllWorktreeLineage: vi.fn(),
removeWorktreeLineage: vi.fn(),
@@ -0,0 +1,35 @@
import type { TestStore } from './worktrees-test-ipc-surface'
export function configureMetadataPruningStoreMocks(
store: TestStore,
expectedSettings: unknown
): void {
store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation(
(...args: unknown[]) => {
const repo = args[0] as {
id: string
path: string
connectionId?: string | null
executionHostId?: string | null
kind?: string
}
return {
repo: {
id: repo.id,
path: repo.path,
connectionId: repo.connectionId,
executionHostId: repo.executionHostId,
kind: repo.kind === 'folder' ? 'folder' : 'git',
expectedRepo: repo
},
routing: {
expectedProject: undefined,
expectedProjectUpdatedAt: undefined,
expectedSettings
},
metadata: []
}
}
)
store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([])
}
+16
View File
@@ -166,6 +166,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
getAllWorktreeMeta: vi.fn(),
captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn(),
addRetiredWorktreeName: vi.fn(),
@@ -211,6 +212,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
store.getSettings.mockReset()
store.getWorktreeMeta.mockReset()
store.getAllWorktreeMeta.mockReset()
store.captureNativeLocalWorktreeMetadataScanExpectation.mockReset()
store.setWorktreeMeta.mockReset()
store.removeWorktreeMeta.mockReset()
store.addRetiredWorktreeName.mockReset()
@@ -254,6 +256,20 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
resolveSetupRunnerShellMock.mockReturnValue(undefined)
store.getWorktreeMeta.mockReturnValue(undefined)
store.getAllWorktreeMeta.mockReturnValue({})
store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation((repo) => ({
repo: {
id: repo.id,
path: repo.path,
kind: 'git',
expectedRepo: repo
},
routing: {
expectedProject: undefined,
expectedProjectUpdatedAt: undefined,
expectedSettings: store.getSettings()
},
metadata: []
}))
store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] })
store.setWorktreeMeta.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
@@ -0,0 +1,141 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import { isFolderRepo } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/repo-types'
import {
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
splitWorktreeId
} from '../../../../shared/worktree/id'
import type { GitWorktreeInfo } from '../../../../shared/worktree/types'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import type { Store } from '../../../persistence/loading-store/store'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot'
import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot'
import { isLocalWorktreeScanGenerationCurrent } from '../../../local-worktree-scan-generation'
import { localWorktreePathsExistOrAreUnverifiable } from '../../../local-worktree-path-presence'
import { worktreeRetentionPathComparisonKey } from '../../../worktree-retention-path-comparison'
type AuthoritativeLocalMetadataPruneArgs = {
store: Store
repo: Repo
gitWorktrees: readonly GitWorktreeInfo[]
scan: NativeLocalWorktreeMetadataScanExpectation
scanGeneration: number
platform?: NodeJS.Platform
pathsExistOrAreUnverifiable?: (
pathValues: readonly string[],
options?: { signal?: AbortSignal }
) => Promise<ReadonlyMap<string, boolean>>
isCallerCurrent?: () => boolean
signal?: AbortSignal
}
export type AuthoritativeLocalMetadataPruneResult = Readonly<{
removedWorktreeIds: readonly string[]
preservedMetadataCandidateIds: ReadonlySet<string>
scanGenerationCurrent: boolean
}>
export async function pruneMetadataMissingFromAuthoritativeLocalScan({
store,
repo,
gitWorktrees,
scan,
scanGeneration,
platform = process.platform,
pathsExistOrAreUnverifiable = localWorktreePathsExistOrAreUnverifiable,
isCallerCurrent = () => true,
signal
}: AuthoritativeLocalMetadataPruneArgs): Promise<AuthoritativeLocalMetadataPruneResult> {
const capturedCandidateIds = scan.metadata.map(({ worktreeId }) => worktreeId)
const result = (
removedWorktreeIds: readonly string[],
scanGenerationCurrent: boolean
): AuthoritativeLocalMetadataPruneResult => {
const removedIds = new Set(removedWorktreeIds)
return {
removedWorktreeIds,
preservedMetadataCandidateIds: new Set(
capturedCandidateIds.filter((worktreeId) => !removedIds.has(worktreeId))
),
scanGenerationCurrent
}
}
const generationCurrent = () => isLocalWorktreeScanGenerationCurrent(repo.id, scanGeneration)
const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id)
if (
!generationCurrent() ||
!isCallerCurrent() ||
gitWorktrees.length === 0 ||
repoOwners.length !== 1 ||
repo.id !== scan.repo.id ||
repo.path !== scan.repo.path ||
repo.connectionId ||
isFolderRepo(repo) ||
getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID ||
(platform === 'win32' &&
(isWslUncPath(repo.path) || gitWorktrees.some(({ path }) => isWslUncPath(path))))
) {
return result([], generationCurrent())
}
const livePathKeys = new Set([
// Why: Git can canonicalize a symlinked main checkout differently from the configured path.
worktreeRetentionPathComparisonKey(repo.path, platform),
...gitWorktrees.map((worktree) => worktreeRetentionPathComparisonKey(worktree.path, platform))
])
const probeCandidates = scan.metadata.flatMap((metadata) => {
const { worktreeId } = metadata
const parsed = splitWorktreeId(worktreeId)
const nativeAbsolute = parsed
? platform === 'win32'
? isWindowsAbsolutePathLike(parsed.worktreePath)
: parsed.worktreePath.startsWith('/')
: false
if (
parsed?.repoId !== repo.id ||
!nativeAbsolute ||
isWslUncPath(parsed.worktreePath) ||
worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) ||
livePathKeys.has(worktreeRetentionPathComparisonKey(parsed.worktreePath, platform))
) {
return []
}
return [{ metadata, pathValue: parsed.worktreePath }]
})
const presenceByPath = await pathsExistOrAreUnverifiable(
probeCandidates.map(({ pathValue }) => pathValue),
{ signal }
)
if (!generationCurrent() || !isCallerCurrent()) {
return result([], generationCurrent())
}
const missingMetadata = probeCandidates.flatMap(({ metadata, pathValue }) =>
presenceByPath.get(pathValue) === false ? [metadata] : []
)
if (missingMetadata.length === 0) {
return result([], generationCurrent())
}
if (!generationCurrent() || !isCallerCurrent()) {
return result([], generationCurrent())
}
const removedIds = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo(
scan,
missingMetadata
)
if (removedIds.length > 0) {
const snapshotDirectory = store.getProfileStorageDirectory()
const targets: {
worktreeId: string
executionHostId: typeof LOCAL_EXECUTION_HOST_ID
}[] = removedIds.map((worktreeId) => ({
worktreeId,
executionHostId: LOCAL_EXECUTION_HOST_ID
}))
void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets)
void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets)
}
return result(removedIds, generationCurrent())
}
@@ -4,7 +4,6 @@ import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../../../shared/worktree/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage'
import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning'
import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract'
import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission'
import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract'
@@ -20,8 +19,10 @@ import {
import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model'
import { hasConflictingStoredWorktreeOwner } from './worktree-host-ownership'
import {
applyFreshDetectedWorktreeScanSideEffects,
listDetectedGitWorktrees,
rememberLocalWorktreeRoots
type DetectedWorktreeMetadataPrune,
type DetectedWorktreeSideEffectToken
} from './detected-worktree-scan-cache'
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
@@ -48,6 +49,8 @@ export async function listDetectedWorktreesForCapturedRepo(
try {
let gitWorktrees: GitWorktreeInfo[]
let freshScan = true
let sideEffectToken: DetectedWorktreeSideEffectToken | undefined
let metadataPrune: DetectedWorktreeMetadataPrune | undefined
if (isFolderRepo(repo)) {
if (!isCurrent()) {
return null
@@ -97,6 +100,8 @@ export async function listDetectedWorktreesForCapturedRepo(
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
sideEffectToken = scan.sideEffectToken
metadataPrune = scan.metadataPrune
}
const aborted = abortedResult()
if (aborted) {
@@ -115,8 +120,18 @@ export async function listDetectedWorktreesForCapturedRepo(
}
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, {
isCurrent: () => isCurrent() && !providerAbort?.signal.aborted,
sideEffectToken,
signal: providerAbort?.signal
})
const aborted = abortedResult()
if (aborted) {
return aborted
}
if (!isCurrent()) {
return null
}
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return {
@@ -4,7 +4,19 @@ import type { Repo } from '../../../../shared/repo-types'
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { listRepoWorktrees } from '../../../repo-worktrees'
import { registerWorktreeRootsForRepo } from '../../registered-worktree-roots-cache'
import {
getRegisteredWorktreeRootsRevision,
registerWorktreeRootsForRepo
} from '../../registered-worktree-roots-cache'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import {
bumpLocalWorktreeScanGeneration,
getLocalWorktreeScanGeneration,
isLocalWorktreeScanGenerationCurrent,
resetLocalWorktreeScanGenerationsForTests
} from '../../../local-worktree-scan-generation'
import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning'
import { pruneMetadataMissingFromAuthoritativeLocalScan } from './authoritative-local-worktree-metadata-pruning'
// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window.
export const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000
@@ -17,17 +29,31 @@ export type DetectedWorktreeScanCacheEntry = {
export type DetectedWorktreeScan = {
invalidated: boolean
promise: Promise<GitWorktreeInfo[]>
sideEffectToken: DetectedWorktreeSideEffectToken
metadataPrune?: DetectedWorktreeMetadataPrune
}
export type DetectedWorktreeSideEffectToken = Readonly<{
generation: number
authorizedRootsRevision: number
}>
export type DetectedWorktreeMetadataPrune = Readonly<{
expectation: NativeLocalWorktreeMetadataScanExpectation
}>
export type DetectedWorktreeScanResult = {
gitWorktrees: GitWorktreeInfo[]
fresh: boolean
sideEffectToken?: DetectedWorktreeSideEffectToken
metadataPrune?: DetectedWorktreeMetadataPrune
}
export const detectedWorktreeScanCache = new Map<string, DetectedWorktreeScanCacheEntry>()
export const detectedWorktreeScanInFlight = new Map<string, DetectedWorktreeScan>()
export function invalidateDetectedWorktreeScanCache(repoId: string): void {
bumpLocalWorktreeScanGeneration(repoId)
const keyPrefix = `${repoId}\0`
for (const key of new Set([
...detectedWorktreeScanCache.keys(),
@@ -53,6 +79,7 @@ export function __resetDetectedWorktreeScanCacheForTests(): void {
}
detectedWorktreeScanCache.clear()
detectedWorktreeScanInFlight.clear()
resetLocalWorktreeScanGenerationsForTests()
}
export function __getDetectedWorktreeScanCacheStatsForTests(): {
@@ -88,21 +115,47 @@ export async function listDetectedGitWorktrees(
return { gitWorktrees: await inFlight.promise, fresh: false }
}
// Why: capture before invoking Git because listing can mutate synchronously before its first await.
// WSL listings can use UNC paths while legacy metadata keeps Linux paths; v1 cannot prove
// those aliases equivalent, so only native-host scans carry destructive expectations.
const generation = getLocalWorktreeScanGeneration(repo.id)
const authorizedRootsRevision = getRegisteredWorktreeRootsRevision(repo.id)
const metadataPruneExpectation = localWorktreeGitOptions.wslDistro
? undefined
: store.captureNativeLocalWorktreeMetadataScanExpectation(repo)
const scan: DetectedWorktreeScan = {
invalidated: false,
promise: listRepoWorktrees(repo, localWorktreeGitOptions)
promise: listRepoWorktrees(repo, localWorktreeGitOptions),
sideEffectToken: { generation, authorizedRootsRevision },
...(metadataPruneExpectation
? {
metadataPrune: {
expectation: metadataPruneExpectation
}
}
: {})
}
detectedWorktreeScanInFlight.set(cacheKey, scan)
try {
const gitWorktrees = await scan.promise
const routingUnchanged =
getDetectedWorktreeScanCacheKey(repo.id, getLocalProjectWorktreeGitOptions(store, repo)) ===
cacheKey
// Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward.
if (!scan.invalidated) {
const generationCurrent = isLocalWorktreeScanGenerationCurrent(repo.id, generation)
if (!scan.invalidated && routingUnchanged && generationCurrent) {
detectedWorktreeScanCache.set(cacheKey, {
worktrees: gitWorktrees,
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
})
}
return { gitWorktrees, fresh: !scan.invalidated }
const fresh = !scan.invalidated && routingUnchanged && generationCurrent
return {
gitWorktrees,
fresh,
...(fresh ? { sideEffectToken: scan.sideEffectToken } : {}),
...(fresh && scan.metadataPrune ? { metadataPrune: scan.metadataPrune } : {})
}
} finally {
if (detectedWorktreeScanInFlight.get(cacheKey) === scan) {
detectedWorktreeScanInFlight.delete(cacheKey)
@@ -110,6 +163,63 @@ export async function listDetectedGitWorktrees(
}
}
export async function applyFreshDetectedWorktreeScanSideEffects(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[],
metadataPrune?: DetectedWorktreeMetadataPrune,
options: {
isCurrent?: () => boolean
sideEffectToken?: DetectedWorktreeSideEffectToken
signal?: AbortSignal
} = {}
): Promise<boolean> {
const { isCurrent = () => true, sideEffectToken, signal } = options
const generationCurrent = () =>
sideEffectToken === undefined ||
isLocalWorktreeScanGenerationCurrent(repo.id, sideEffectToken.generation)
if (!generationCurrent() || !isCurrent()) {
return false
}
let preservedMetadataCandidateIds: ReadonlySet<string> | undefined
if (metadataPrune) {
if (!sideEffectToken) {
return false
}
const pruneResult = await pruneMetadataMissingFromAuthoritativeLocalScan({
store,
repo,
gitWorktrees,
scan: metadataPrune.expectation,
scanGeneration: sideEffectToken.generation,
isCallerCurrent: isCurrent,
signal
})
if (!pruneResult.scanGenerationCurrent || !generationCurrent() || !isCurrent()) {
return false
}
preservedMetadataCandidateIds = pruneResult.preservedMetadataCandidateIds
}
if (!generationCurrent() || !isCurrent()) {
return false
}
if (
sideEffectToken &&
getRegisteredWorktreeRootsRevision(repo.id) !== sideEffectToken.authorizedRootsRevision
) {
return false
}
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(
store,
repo,
gitWorktrees,
preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined
)
return true
}
export function getDetectedWorktreeScanCacheKey(
repoId: string,
localWorktreeGitOptions: { wslDistro?: string } = {}
@@ -2,7 +2,6 @@ import { ipcMain } from 'electron'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { getSshGitProvider } from '../../../providers/ssh-git-dispatch'
import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning'
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry'
import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement'
import {
@@ -13,8 +12,10 @@ import {
} from './ssh-worktree-fallback'
import { listVisibleFolderWorkspaces } from './folder-workspace-catalog'
import {
applyFreshDetectedWorktreeScanSideEffects,
listDetectedGitWorktrees,
rememberLocalWorktreeRoots
type DetectedWorktreeMetadataPrune,
type DetectedWorktreeSideEffectToken
} from './detected-worktree-scan-cache'
import {
loggedUnavailableSshGitProviders,
@@ -88,6 +89,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
try {
let gitWorktrees
let freshScan = true
let sideEffectToken: DetectedWorktreeSideEffectToken | undefined
let metadataPrune: DetectedWorktreeMetadataPrune | undefined
if (isFolderRepo(repo)) {
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
@@ -116,10 +119,19 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
sideEffectToken = scan.sideEffectToken
metadataPrune = scan.metadataPrune
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
await applyFreshDetectedWorktreeScanSideEffects(
store,
repo,
gitWorktrees,
metadataPrune,
{
sideEffectToken
}
)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
const metadata = metadataForRepo(repo)
@@ -169,6 +181,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
try {
let gitWorktrees
let freshScan = true
let sideEffectToken: DetectedWorktreeSideEffectToken | undefined
let metadataPrune: DetectedWorktreeMetadataPrune | undefined
if (isFolderRepo(repo)) {
return listVisibleFolderWorkspaces(store, repo)
} else if (repo.connectionId) {
@@ -197,10 +211,13 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
const scan = await listDetectedGitWorktrees(store, repo)
gitWorktrees = scan.gitWorktrees
freshScan = scan.fresh
sideEffectToken = scan.sideEffectToken
metadataPrune = scan.metadataPrune
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, {
sideEffectToken
})
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { statMock } = vi.hoisted(() => ({
statMock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ stat: statMock }))
import {
LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS,
localWorktreePathExistsOrIsUnverifiable,
localWorktreePathsExistOrAreUnverifiable
} from './local-worktree-path-presence'
function fsError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(code), { code })
}
function installFakeAbortSignalTimeout(): AbortController[] {
const controllers: AbortController[] = []
vi.spyOn(AbortSignal, 'timeout').mockImplementation((timeoutMs) => {
const controller = new AbortController()
controllers.push(controller)
setTimeout(
() => controller.abort(new DOMException('The operation timed out.', 'TimeoutError')),
timeoutMs
)
return controller.signal
})
return controllers
}
describe('local worktree path presence', () => {
beforeEach(() => {
statMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('returns one result per distinct path and only treats ENOENT/ENOTDIR as missing', async () => {
statMock.mockImplementation(async (pathValue: string) => {
if (pathValue === '/missing') {
throw fsError('ENOENT')
}
if (pathValue === '/not-a-directory') {
throw fsError('ENOTDIR')
}
if (pathValue === '/permission-denied') {
throw fsError('EACCES')
}
return {}
})
const result = await localWorktreePathsExistOrAreUnverifiable([
'/present',
'/missing',
'/not-a-directory',
'/permission-denied',
'/present'
])
expect([...result.entries()]).toEqual([
['/present', true],
['/missing', false],
['/not-a-directory', false],
['/permission-denied', true]
])
expect(statMock).toHaveBeenCalledTimes(4)
})
it('does not allocate a deadline when there are no paths to probe', async () => {
const timeout = vi.spyOn(AbortSignal, 'timeout')
await expect(localWorktreePathsExistOrAreUnverifiable([])).resolves.toEqual(new Map())
expect(timeout).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
})
it('globally bounds stat calls across simultaneous bulk probes', async () => {
let active = 0
let maximumActive = 0
const pending: (() => void)[] = []
statMock.mockImplementation(
() =>
new Promise((resolve) => {
active += 1
maximumActive = Math.max(maximumActive, active)
pending.push(() => {
active -= 1
resolve({})
})
})
)
const first = localWorktreePathsExistOrAreUnverifiable([
'/first-1',
'/first-2',
'/first-3',
'/first-4'
])
const second = localWorktreePathsExistOrAreUnverifiable([
'/second-1',
'/second-2',
'/second-3',
'/second-4'
])
let resolved = 0
while (resolved < 8) {
while (pending.length === 0) {
await Promise.resolve()
}
pending.shift()?.()
resolved += 1
await Promise.resolve()
}
await Promise.all([first, second])
expect(maximumActive).toBe(2)
expect(statMock).toHaveBeenCalledTimes(8)
})
it('uses the same fail-closed classification for a single probe', async () => {
statMock.mockRejectedValueOnce(fsError('ENOENT'))
statMock.mockRejectedValueOnce(fsError('ENOTDIR'))
statMock.mockRejectedValueOnce(fsError('EIO'))
await expect(localWorktreePathExistsOrIsUnverifiable('/missing')).resolves.toBe(false)
await expect(localWorktreePathExistsOrIsUnverifiable('/parent-missing')).resolves.toBe(false)
await expect(localWorktreePathExistsOrIsUnverifiable('/unverifiable')).resolves.toBe(true)
})
it('settles timed-out batches without exceeding or leaking the global stat bound', async () => {
vi.useFakeTimers()
const timeoutControllers = installFakeAbortSignalTimeout()
const releaseStats: (() => void)[] = []
let returnMissing = false
statMock.mockImplementation(() =>
returnMissing
? Promise.reject(fsError('ENOENT'))
: new Promise((resolve) => releaseStats.push(() => resolve({})))
)
try {
const firstPaths = ['/first-1', '/first-2', '/first-3', '/first-4']
const first = localWorktreePathsExistOrAreUnverifiable(firstPaths)
let firstSettled = false
void first.then(() => {
firstSettled = true
})
await vi.advanceTimersByTimeAsync(0)
expect(statMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS - 1)
expect(firstSettled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(first).resolves.toEqual(
new Map(firstPaths.map((pathValue) => [pathValue, true]))
)
expect(statMock).toHaveBeenCalledTimes(2)
const secondPaths = ['/second-1', '/second-2', '/second-3']
const second = localWorktreePathsExistOrAreUnverifiable(secondPaths)
await vi.advanceTimersByTimeAsync(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS)
await expect(second).resolves.toEqual(
new Map(secondPaths.map((pathValue) => [pathValue, true]))
)
expect(statMock).toHaveBeenCalledTimes(2)
returnMissing = true
releaseStats.splice(0).forEach((release) => release())
await vi.advanceTimersByTimeAsync(0)
await expect(localWorktreePathsExistOrAreUnverifiable(['/after-release'])).resolves.toEqual(
new Map([['/after-release', false]])
)
expect(statMock).toHaveBeenCalledTimes(3)
} finally {
timeoutControllers.forEach((controller) => controller.abort())
releaseStats.splice(0).forEach((release) => release())
await Promise.resolve()
await Promise.resolve()
}
})
it('settles promptly and fail closed when the caller aborts hung probes', async () => {
vi.useFakeTimers()
const timeoutControllers = installFakeAbortSignalTimeout()
const releaseStats: (() => void)[] = []
statMock.mockImplementation(
() => new Promise((resolve) => releaseStats.push(() => resolve({})))
)
const controller = new AbortController()
const paths = ['/abort-1', '/abort-2', '/abort-3']
try {
const result = localWorktreePathsExistOrAreUnverifiable(paths, {
signal: controller.signal
})
await vi.advanceTimersByTimeAsync(0)
expect(statMock).toHaveBeenCalledTimes(2)
controller.abort()
await expect(result).resolves.toEqual(new Map(paths.map((pathValue) => [pathValue, true])))
expect(vi.getTimerCount()).toBeGreaterThan(0)
} finally {
timeoutControllers.forEach((timeoutController) => timeoutController.abort())
releaseStats.splice(0).forEach((release) => release())
await Promise.resolve()
await Promise.resolve()
}
})
})
+72
View File
@@ -0,0 +1,72 @@
import { stat } from 'node:fs/promises'
import { mapWithConcurrency } from '../shared/map-with-concurrency'
import { PrioritySemaphore } from '../shared/priority-semaphore'
// Why: share one bound across polling callers so slow mounts cannot multiply libuv work.
const LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY = 2
export const LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS = 2_000
const localWorktreePathProbeSemaphore = new PrioritySemaphore(LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY)
function probeDeadline(signal?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS)
return signal ? AbortSignal.any([signal, timeout]) : timeout
}
async function probeLocalWorktreePath(pathValue: string, signal: AbortSignal): Promise<boolean> {
let release: () => void
try {
release = await localWorktreePathProbeSemaphore.acquire(0, signal)
} catch {
return true
}
if (signal.aborted) {
release()
return true
}
const presence = stat(pathValue)
.then(
() => true,
(error) => {
const code = (error as NodeJS.ErrnoException | undefined)?.code
return code !== 'ENOENT' && code !== 'ENOTDIR'
}
)
.finally(release)
if (signal.aborted) {
return true
}
return new Promise((resolve) => {
const finish = (value: boolean): void => {
signal.removeEventListener('abort', onAbort)
resolve(value)
}
const onAbort = (): void => finish(true)
signal.addEventListener('abort', onAbort, { once: true })
void presence.then(finish)
})
}
/** A non-absence filesystem error cannot prove a native-local worktree disappeared. */
export function localWorktreePathExistsOrIsUnverifiable(
pathValue: string,
options: { signal?: AbortSignal } = {}
): Promise<boolean> {
return probeLocalWorktreePath(pathValue, probeDeadline(options.signal))
}
export async function localWorktreePathsExistOrAreUnverifiable(
pathValues: readonly string[],
options: { signal?: AbortSignal } = {}
): Promise<ReadonlyMap<string, boolean>> {
const uniquePaths = [...new Set(pathValues)]
if (uniquePaths.length === 0) {
return new Map()
}
const signal = probeDeadline(options.signal)
const results = await mapWithConcurrency(
uniquePaths,
LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY,
async (pathValue) => [pathValue, await probeLocalWorktreePath(pathValue, signal)] as const
)
return new Map(results)
}
@@ -0,0 +1,25 @@
const generationByRepoId = new Map<string, number>()
let generationSequence = 0
export function getLocalWorktreeScanGeneration(repoId: string): number {
const existing = generationByRepoId.get(repoId)
if (existing !== undefined) {
return existing
}
const generation = ++generationSequence
generationByRepoId.set(repoId, generation)
return generation
}
export function bumpLocalWorktreeScanGeneration(repoId: string): void {
generationByRepoId.set(repoId, ++generationSequence)
}
export function isLocalWorktreeScanGenerationCurrent(repoId: string, generation: number): boolean {
return getLocalWorktreeScanGeneration(repoId) === generation
}
export function resetLocalWorktreeScanGenerationsForTests(): void {
generationSequence += 1
generationByRepoId.clear()
}
@@ -11,7 +11,10 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { SessionInfo } from '../daemon/types'
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
import type { Store } from '../persistence'
@@ -23,7 +26,6 @@ import type {
} from './pty-registry'
const LARGE_SESSION_COUNT = 150_000
// Why: the hydrator pulls the daemon provider through this module-level
// getter. Stubbing it lets us drive the offline / throwing / live paths
// without spinning up real sockets.
@@ -32,32 +34,53 @@ vi.mock('../daemon/daemon-init', () => ({
getDaemonProvider: () => getDaemonProviderMock()
}))
// Why: the hydrator builds its worktreeId → connectionId map through
// listRepoWorktrees(repo). The git I/O is out of scope for this unit; the mock
// also lets count tests prove repos without live sessions launch no Git work.
const listRepoWorktreesMock = vi.fn()
vi.mock('../repo-worktrees', () => ({
listRepoWorktrees: (repo: unknown) => listRepoWorktreesMock(repo)
const getLocalProjectWorktreeGitOptionsMock = vi.fn()
vi.mock('../project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: (store: unknown, repo: unknown) =>
getLocalProjectWorktreeGitOptionsMock(store, repo)
}))
// Why: hydrateLocalPtyRegistryAtBoot accepts `Pick<Store, 'getRepos'>` and
// the hydrator only reads `id` + `connectionId` off each Repo, but Repo's
// required fields (path, displayName, badgeColor, addedAt) still have to be
// present at the type level. Filling them with placeholder values keeps the
// test schema-compliant without coupling to anything the hydrator doesn't
// touch.
// Why: the hydrator builds its worktreeId → connectionId map through
// listLocalRepoWorktreesStrict(repo). The git I/O is out of scope for this
// unit; the mock also lets count tests prove repos without live sessions
// launch no Git work.
const listLocalRepoWorktreesStrictMock = vi.fn()
vi.mock('../repo-worktrees', () => ({
listLocalRepoWorktreesStrict: (
repo: unknown,
options?: { signal?: AbortSignal; wslDistro?: string }
) => listLocalRepoWorktreesStrictMock(repo, options)
}))
// The runtime option helper accepts Store; mocks keep this fixture scoped to hydration reads.
function makeStore(
repos: { id: string; connectionId?: string | null }[] = []
): Pick<Store, 'getRepos'> {
repos: {
id: string
connectionId?: string | null
executionHostId?: Repo['executionHostId']
kind?: Repo['kind']
path?: string
}[] = [],
worktreeMeta: Record<string, WorktreeMeta> = {}
): Store {
const built: Repo[] = repos.map((r) => ({
id: r.id,
path: `/tmp/${r.id}`,
path: r.path ?? `/tmp/${r.id}`,
displayName: r.id,
badgeColor: '#000000',
addedAt: 0,
connectionId: r.connectionId ?? null
connectionId: r.connectionId ?? null,
executionHostId: r.executionHostId ?? null,
kind: r.kind
}))
return { getRepos: () => built }
return {
getRepos: () => built,
getAllWorktreeMeta: () => worktreeMeta,
getAllWorktreeMetaForHost: (hostId) =>
Object.fromEntries(
Object.entries(worktreeMeta).filter(([, meta]) => !meta.hostId || meta.hostId === hostId)
)
} as Store
}
function makeProvider(sessions: SessionInfo[]): Pick<DaemonPtyAdapter, 'listSessions'> {
@@ -66,6 +89,12 @@ function makeProvider(sessions: SessionInfo[]): Pick<DaemonPtyAdapter, 'listSess
}
}
function makeProviderGroup(adapters: Pick<DaemonPtyAdapter, 'listSessions'>[]): {
getAllAdapters: () => Pick<DaemonPtyAdapter, 'listSessions'>[]
} {
return { getAllAdapters: () => adapters }
}
function makeLocalSessions(repoId: string, worktreePath: string, count: number): SessionInfo[] {
const sessions: SessionInfo[] = []
for (let index = 0; index < count; index += 1) {
@@ -98,6 +127,8 @@ function createDeferred<T>(): {
// a coherent pair.
async function loadFresh(): Promise<{
hydrate: typeof HydrateFn
deadlineMs: number
gitEnumerationConcurrency: number
listRegisteredPtys: typeof ListFn
registerPty: typeof RegisterFn
unregisterPty: typeof UnregisterFn
@@ -107,6 +138,8 @@ async function loadFresh(): Promise<{
const registryMod = await import('./pty-registry')
return {
hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot,
deadlineMs: hydrateMod.LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS,
gitEnumerationConcurrency: hydrateMod.LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY,
listRegisteredPtys: registryMod.listRegisteredPtys,
registerPty: registryMod.registerPty,
unregisterPty: registryMod.unregisterPty
@@ -116,7 +149,8 @@ async function loadFresh(): Promise<{
describe('hydrateLocalPtyRegistryAtBoot', () => {
beforeEach(() => {
getDaemonProviderMock.mockReset()
listRepoWorktreesMock.mockReset()
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
listLocalRepoWorktreesStrictMock.mockReset()
})
it('no-op when daemon provider is null at first call (retries on later activation)', async () => {
@@ -126,19 +160,19 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
await hydrate(makeStore([{ id: 'repo-a' }]))
expect(listRegisteredPtys()).toHaveLength(0)
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
// Why: the design says the hasHydrated guard must stay false until a
// provider is obtained, so a later macOS dock re-activation can retry.
// Provider becomes available; second call should now perform the pass.
const provider = makeProvider([])
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([])
listLocalRepoWorktreesStrictMock.mockResolvedValue([])
await hydrate(makeStore([{ id: 'repo-a' }]))
expect(provider.listSessions).toHaveBeenCalledTimes(1)
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('catches provider.listSessions rejection and does not throw', async () => {
@@ -147,7 +181,9 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
listSessions: vi.fn().mockRejectedValue(new Error('socket EPIPE'))
}
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([{ path: '/local/Triton', isMainWorktree: true }])
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/local/Triton', isMainWorktree: true }
])
// Why: the renderer-side step-2 merge fallback covers this case. The
// hydrator must not surface the failure to the caller — it logs and
@@ -157,7 +193,234 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
warnSpy.mockRestore()
expect(listRegisteredPtys()).toHaveLength(0)
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('coalesces concurrent calls onto one inventory attempt', async () => {
const { hydrate } = await loadFresh()
const inventory = createDeferred<SessionInfo[]>()
const provider = { listSessions: vi.fn(() => inventory.promise) }
getDaemonProviderMock.mockReturnValue(provider)
const first = hydrate(makeStore())
const second = hydrate(makeStore())
expect(first).toBe(second)
expect(provider.listSessions).toHaveBeenCalledOnce()
inventory.resolve([])
await first
})
it('retries after worktree enumeration throws', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const ptyId = 'repo-a::/local/repo-a@@00000001'
const provider = makeProvider([
{ sessionId: ptyId, pid: 4001, cwd: '/local/repo-a' } as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
listLocalRepoWorktreesStrictMock
.mockRejectedValueOnce(new Error('git unavailable'))
.mockResolvedValue([
{ path: '/local/repo-a', head: '', branch: '', isBare: false, isMainWorktree: true }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await hydrate(makeStore([{ id: 'repo-a' }]))
expect(listRegisteredPtys()).toHaveLength(0)
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledOnce()
await hydrate(makeStore([{ id: 'repo-a' }]))
warnSpy.mockRestore()
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })])
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(2)
})
it('forwards the WSL project runtime and live deadline signal to strict enumeration', async () => {
const { hydrate } = await loadFresh()
const ptyId = 'repo-a::/mnt/c/repo-a@@00000001'
const store = makeStore([{ id: 'repo-a', path: '/mnt/c/repo-a' }])
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4001, cwd: '/mnt/c/repo-a' } as unknown as SessionInfo
])
)
getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' })
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/mnt/c/repo-a', head: '', branch: '', isBare: false, isMainWorktree: true }
])
await hydrate(store)
const [repo, options] = listLocalRepoWorktreesStrictMock.mock.calls[0] as [
Repo,
{ signal: AbortSignal; wslDistro?: string }
]
expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(store, repo)
expect(options.wslDistro).toBe('Ubuntu')
expect(options.signal).toBeInstanceOf(AbortSignal)
expect(options.signal.aborted).toBe(false)
})
it('resolves by the boot deadline and lets a later call retry stalled enumeration', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { hydrate, deadlineMs, listRegisteredPtys } = await loadFresh()
const ptyId = 'repo-a::/local/repo-a@@00000001'
const provider = makeProvider([
{ sessionId: ptyId, pid: 4001, cwd: '/local/repo-a' } as unknown as SessionInfo
])
const signals: AbortSignal[] = []
getDaemonProviderMock.mockReturnValue(provider)
listLocalRepoWorktreesStrictMock
.mockImplementationOnce((_repo: Repo, options?: { signal?: AbortSignal }) => {
if (options?.signal) {
signals.push(options.signal)
}
return new Promise(() => {})
})
.mockImplementation(async (_repo: Repo, options?: { signal?: AbortSignal }) => {
if (options?.signal) {
signals.push(options.signal)
}
return [
{
path: '/local/repo-a',
head: '',
branch: '',
isBare: false,
isMainWorktree: true
}
]
})
let settled = false
const first = hydrate(makeStore([{ id: 'repo-a' }])).then(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(deadlineMs - 1)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(settled).toBe(true)
await first
expect(signals[0]?.aborted).toBe(true)
await hydrate(makeStore([{ id: 'repo-a' }]))
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(2)
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })])
expect(vi.getTimerCount()).toBe(0)
} finally {
warnSpy.mockRestore()
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('bounds concurrent Git worktree enumeration', async () => {
const { hydrate, gitEnumerationConcurrency } = await loadFresh()
const repoCount = 6
const gate = createDeferred<void>()
let active = 0
let maxActive = 0
const sessions = Array.from({ length: repoCount }, (_, index) => {
const repoId = `repo-${index}`
return {
sessionId: `${repoId}::/local/${repoId}@@0000000${index}`,
pid: 4000 + index,
cwd: `/local/${repoId}`
} as unknown as SessionInfo
})
getDaemonProviderMock.mockReturnValue(makeProvider(sessions))
listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => {
active += 1
maxActive = Math.max(maxActive, active)
await gate.promise
active -= 1
return [
{ path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true }
]
})
const hydration = hydrate(
makeStore(Array.from({ length: repoCount }, (_, index) => ({ id: `repo-${index}` })))
)
let startedBeforeRelease = 0
try {
await vi.waitFor(() =>
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(gitEnumerationConcurrency)
)
startedBeforeRelease = listLocalRepoWorktreesStrictMock.mock.calls.length
} finally {
gate.resolve(undefined)
await hydration
}
expect(startedBeforeRelease).toBe(gitEnumerationConcurrency)
expect(maxActive).toBe(gitEnumerationConcurrency)
})
it('registers successful adapter rows but retries a failed adapter', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const sessionA = {
sessionId: 'repo-a::/local/repo-a@@00000001',
pid: 4001,
cwd: '/local/repo-a'
} as unknown as SessionInfo
const sessionB = {
sessionId: 'repo-b::/local/repo-b@@00000002',
pid: 4002,
cwd: '/local/repo-b'
} as unknown as SessionInfo
const adapterA = makeProvider([sessionA])
const adapterB = {
listSessions: vi
.fn()
.mockRejectedValueOnce(new Error('legacy unavailable'))
.mockRejectedValueOnce(new Error('legacy unavailable'))
.mockResolvedValue([sessionB])
}
getDaemonProviderMock.mockReturnValue(makeProviderGroup([adapterA, adapterB]))
listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [
{ path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = makeStore([{ id: 'repo-a' }, { id: 'repo-b' }])
await hydrate(store)
expect(listRegisteredPtys().map((pty) => pty.ptyId)).toEqual([sessionA.sessionId])
await hydrate(store)
warnSpy.mockRestore()
expect(
listRegisteredPtys()
.map((pty) => pty.ptyId)
.sort()
).toEqual([sessionA.sessionId, sessionB.sessionId].sort())
})
it('retries an all-adapter inventory failure and latches only the complete retry', async () => {
const { hydrate } = await loadFresh()
const firstAdapter = {
listSessions: vi.fn().mockRejectedValueOnce(new Error('current down')).mockResolvedValue([])
}
const secondAdapter = {
listSessions: vi.fn().mockRejectedValueOnce(new Error('legacy down')).mockResolvedValue([])
}
getDaemonProviderMock.mockReturnValue(makeProviderGroup([firstAdapter, secondAdapter]))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await hydrate(makeStore())
await hydrate(makeStore())
await hydrate(makeStore())
warnSpy.mockRestore()
expect(firstAdapter.listSessions).toHaveBeenCalledTimes(2)
expect(secondAdapter.listSessions).toHaveBeenCalledTimes(2)
})
it('does not clobber a pre-existing registry pid with a null pid from listSessions', async () => {
@@ -183,7 +446,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
{ sessionId: ptyId, pid: null, cwd: '/local/Triton' } as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
])
@@ -193,7 +456,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
expect(entry).toBeDefined()
expect(entry!.pid).toBe(12345)
expect(entry!.paneKey).toBe('tab-1:1')
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('does not clobber a pty:spawn registration that arrives during worktree enumeration', async () => {
@@ -207,10 +470,10 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
createDeferred<
{ path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[]
>()
listRepoWorktreesMock.mockReturnValue(worktrees.promise)
listLocalRepoWorktreesStrictMock.mockReturnValue(worktrees.promise)
const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1))
registerPty({
ptyId,
worktreeId: 'repo-a::/local/Triton',
@@ -244,10 +507,10 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
createDeferred<
{ path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[]
>()
listRepoWorktreesMock.mockReturnValue(worktrees.promise)
listLocalRepoWorktreesStrictMock.mockReturnValue(worktrees.promise)
const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1))
unregisterPty(ptyId)
worktrees.resolve([
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
@@ -266,7 +529,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
{ sessionId: ptyId, pid: 999, cwd: '/remote/Stingray' } as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/remote/Stingray', head: '', branch: '', isBare: false, isMainWorktree: true }
])
@@ -276,7 +539,24 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
// visible to the local process sampler. Mirrors the spawn-time gate
// around `registerPty` in `pty.ts`'s `pty:spawn` handler.
expect(listRegisteredPtys()).toHaveLength(0)
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('skips paired-runtime repos even when connectionId is absent', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const provider = makeProvider([
{
sessionId: 'repo-runtime::/runtime/Stingray@@feedface',
pid: 999,
cwd: '/runtime/Stingray'
} as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
await hydrate(makeStore([{ id: 'repo-runtime', executionHostId: 'runtime:environment-1' }]))
expect(listRegisteredPtys()).toHaveLength(0)
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('registers a local session whose worktree is in the store with the daemon-published pid', async () => {
@@ -287,7 +567,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
{ sessionId: ptyId, pid: 4242, cwd: '/local/Triton' } as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
])
@@ -299,6 +579,221 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
expect(entry!.worktreeId).toBe('repo-a::/local/Triton')
})
it('matches Windows worktree path spelling while preserving the daemon worktree id', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const worktreeId = 'repo-a::C:/Users/Neil/Orca'
const ptyId = `${worktreeId}@@cafebabe`
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4242, cwd: 'C:/Users/Neil/Orca' } as unknown as SessionInfo
])
)
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{
path: 'c:\\users\\neil\\orca',
head: '',
branch: '',
isBare: false,
isMainWorktree: true
}
])
await hydrate(makeStore([{ id: 'repo-a', path: 'C:\\Users\\Neil\\Orca' }]))
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId, worktreeId })])
})
it('fails closed when live worktrees collide on one normalized key', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const ptyId = 'repo-a::C:/Users/Neil/Orca@@cafebabe'
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4242, cwd: 'C:/Users/Neil/Orca' } as unknown as SessionInfo
])
)
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{
path: 'C:/Users/Neil/Orca',
head: '',
branch: '',
isBare: false,
isMainWorktree: true
},
{
path: 'c:\\users\\neil\\orca',
head: '',
branch: '',
isBare: false,
isMainWorktree: false
}
])
await hydrate(makeStore([{ id: 'repo-a', path: 'C:/Users/Neil/Orca' }]))
expect(listRegisteredPtys()).toHaveLength(0)
})
it('matches local WSL UNC aliases without treating WSL as a remote host', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const worktreeId = 'repo-a::\\\\wsl$\\Ubuntu\\home\\neil\\orca'
const ptyId = `${worktreeId}@@cafebabe`
getDaemonProviderMock.mockReturnValue(
makeProvider([
{
sessionId: ptyId,
pid: 4242,
cwd: '\\\\wsl$\\Ubuntu\\home\\neil\\orca'
} as unknown as SessionInfo
])
)
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{
path: '\\\\wsl.localhost\\ubuntu\\home\\neil\\orca',
head: '',
branch: '',
isBare: false,
isMainWorktree: true
}
])
await hydrate(makeStore([{ id: 'repo-a', path: '\\\\wsl$\\Ubuntu\\home\\neil\\orca' }]))
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId, worktreeId })])
})
it('preserves an exact repo-backed folder instance id from local-host metadata', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const instanceId =
'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const ptyId = `${instanceId}@@cafebabe`
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo
])
)
await hydrate(
makeStore([{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }], {
[instanceId]: { hostId: 'local' } as WorktreeMeta
})
)
expect(listRegisteredPtys()).toEqual([
expect.objectContaining({ ptyId, worktreeId: instanceId })
])
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('accepts legacy unhosted folder-instance metadata for a uniquely local repo', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const instanceId =
'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const ptyId = `${instanceId}@@cafebabe`
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo
])
)
await hydrate(
makeStore([{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }], {
[instanceId]: {} as WorktreeMeta
})
)
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })])
})
it('rejects legacy folder-instance metadata when the repo id also has a remote owner', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const instanceId =
'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
getDaemonProviderMock.mockReturnValue(
makeProvider([
{
sessionId: `${instanceId}@@cafebabe`,
pid: 4242,
cwd: '/workspace/folder'
} as unknown as SessionInfo
])
)
await hydrate(
makeStore(
[
{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' },
{
id: 'folder-repo',
kind: 'folder',
path: '/workspace/folder',
executionHostId: 'runtime:environment-1'
}
],
{ [instanceId]: {} as WorktreeMeta }
)
)
expect(listRegisteredPtys()).toHaveLength(0)
})
it('accepts explicit local folder-instance metadata beside a same-id runtime owner', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const instanceId =
'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const ptyId = `${instanceId}@@cafebabe`
getDaemonProviderMock.mockReturnValue(
makeProvider([
{ sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo
])
)
await hydrate(
makeStore(
[
{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' },
{
id: 'folder-repo',
kind: 'folder',
path: '/workspace/folder',
executionHostId: 'runtime:environment-1'
}
],
{ [instanceId]: { hostId: 'local' } as WorktreeMeta }
)
)
expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })])
})
it('keeps true folder workspace PTY ids as an accepted hydration gap', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const workspace = {
id: 'folder-workspace-1',
executionHostId: 'local'
} as FolderWorkspace
getDaemonProviderMock.mockReturnValue(
makeProvider([
{
sessionId: 'folder:folder-workspace-1@@cafebabe',
pid: 4242,
cwd: '/workspace/folder'
} as unknown as SessionInfo
])
)
expect(
resolveFolderWorkspaceHost(
{ folderWorkspaces: [workspace], projectGroups: [], repos: [] },
workspace.id
)
).toEqual({ kind: 'local' })
await hydrate(makeStore())
expect(listRegisteredPtys()).toHaveLength(0)
expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled()
})
it('does not register a daemon session whose worktree was removed', async () => {
const { hydrate, listRegisteredPtys } = await loadFresh()
const provider = makeProvider([
@@ -309,13 +804,13 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
} as unknown as SessionInfo
])
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/local/current', head: '', branch: '', isBare: false, isMainWorktree: true }
])
await hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1)
expect(listRegisteredPtys()).toHaveLength(0)
})
@@ -334,7 +829,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
)
)
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [
listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [
{
path: `/local/${repo.id}`,
head: '',
@@ -346,8 +841,8 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
await hydrate(makeStore(repos))
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(activeRepoIds.length)
expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual(
expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(activeRepoIds.length)
expect(listLocalRepoWorktreesStrictMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual(
activeRepoIds
)
expect(listRegisteredPtys()).toHaveLength(activeRepoIds.length)
@@ -374,13 +869,13 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
.mockResolvedValueOnce([sessionA])
.mockResolvedValue([sessionA, sessionB])
getDaemonProviderMock.mockReturnValue({ listSessions })
listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [
listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [
{ path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true }
])
await hydrate(makeStore([{ id: 'repo-a' }, { id: 'repo-b' }]))
expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual([
expect(listLocalRepoWorktreesStrictMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual([
'repo-a',
'repo-b'
])
@@ -398,7 +893,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
const sessions = makeLocalSessions('repo-a', '/local/Triton', LARGE_SESSION_COUNT)
const provider = makeProvider(sessions)
getDaemonProviderMock.mockReturnValue(provider)
listRepoWorktreesMock.mockResolvedValue([
listLocalRepoWorktreesStrictMock.mockResolvedValue([
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
])
+254 -121
View File
@@ -1,146 +1,279 @@
/**
* Boot-time hydration of `pty-registry` from the live daemon.
*
* Why: on warm reattach (fresh Orca process, still-running daemon) the renderer
* hasn't re-mounted every pane, so `pty:spawn` never fired for those sessions
* and they surfaced as "REMOTE" despite being local. Fill the gap once at boot,
* registering only sessions whose repo has no `connectionId` — mirroring the
* spawn-time gate in `src/main/ipc/pty.ts`.
*/
import { getDaemonProvider } from '../daemon/daemon-init'
import { DaemonPtyRouter } from '../daemon/daemon-pty-router'
import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider'
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
import type { SessionInfo } from '../daemon/types'
import { listRegisteredPtys, registerPty } from './pty-registry'
import { listRepoWorktrees } from '../repo-worktrees'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { splitWorktreeId } from '../../shared/worktree/id'
import { isFolderRepo } from '../../shared/repo-kind'
import type { Repo } from '../../shared/repo-types'
import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id'
import { getDaemonProvider } from '../daemon/daemon-init'
import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
import type { DaemonPtyRouter } from '../daemon/daemon-pty-router'
import type { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider'
import type { SessionInfo } from '../daemon/types'
import { isFolderWorkspaceIdForRepo } from '../ipc/worktrees/folder-workspace-model'
import type { Store } from '../persistence'
import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { listLocalRepoWorktreesStrict } from '../repo-worktrees'
import { listRegisteredPtys, registerPty } from './pty-registry'
type HydrationStore = Store
type DaemonInventory = {
complete: boolean
sessions: SessionInfo[]
}
type LocalRepoCatalog = {
byId: Map<string, Repo>
ownerCountById: Map<string, number>
}
// Why: matches existing local Git/read startup budgets while allowing for Defender-heavy repos.
export const LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS = 5_000
export const LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY = 4
// Why: macOS dock reactivation reruns setup; avoid repeating Git I/O and daemon RPC.
let hasHydrated = false
let hydrationInFlight: Promise<void> | null = null
/**
* Read the live daemon session list and register every local session the
* registry doesn't already know about.
*
* Why: failures here are a coverage degradation (the renderer-side union still
* covers the gap), not a correctness regression, so they're swallowed.
*/
export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos'>): Promise<void> {
try {
if (hasHydrated) {
return
}
const provider = getDaemonProvider()
if (!provider) {
// Why: leave hasHydrated false so a later activation can retry once the daemon is up.
return
}
// Why: flip only after a provider exists; retrying a failed RPC on the same socket won't help.
hasHydrated = true
export function hydrateLocalPtyRegistryAtBoot(store: HydrationStore): Promise<void> {
if (hasHydrated) {
return Promise.resolve()
}
if (hydrationInFlight) {
return hydrationInFlight
}
// Why: enumerate Git worktrees only for live-session repos.
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
// Why: verify via live git that a referenced worktree still exists, to avoid resurrecting removed ones.
const liveLocalWorktreeIds = new Set<string>()
const resolvedRepoIds = new Set<string>()
let sessionInfos = await collectSessionInfos(provider)
let alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
// Why: re-read live sessions each pass to avoid resurrecting exited sessions.
for (;;) {
const newlyReferencedRepos = new Map<string, ReturnType<(typeof store)['getRepos']>[number]>()
for (const info of sessionInfos) {
if (alreadyRegistered.has(info.sessionId)) {
continue
}
const { worktreeId } = parsePtySessionId(info.sessionId)
const parsedWorktreeId = worktreeId ? splitWorktreeId(worktreeId) : null
if (!parsedWorktreeId || resolvedRepoIds.has(parsedWorktreeId.repoId)) {
continue
}
const repo = reposById.get(parsedWorktreeId.repoId)
if (!repo || (repo.connectionId ?? null)) {
// Why: unknown and SSH repos cannot be proven local, so do not enumerate them.
resolvedRepoIds.add(parsedWorktreeId.repoId)
continue
}
newlyReferencedRepos.set(repo.id, repo)
const controller = new AbortController()
const deadline = setTimeout(() => {
controller.abort(new Error('Boot-time pty-registry hydration deadline expired'))
}, LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS)
let attempt!: Promise<void>
attempt = waitForPromiseWithSignal(
hydrateLocalPtyRegistry(store, controller.signal),
controller.signal
)
.then((complete) => {
if (complete) {
hasHydrated = true
}
if (newlyReferencedRepos.size === 0) {
break
})
.catch((error) => {
console.warn(
'[memory] Boot-time pty-registry hydration failed:',
error instanceof Error ? error.message : String(error)
)
})
.finally(() => {
clearTimeout(deadline)
if (hydrationInFlight === attempt) {
hydrationInFlight = null
}
})
hydrationInFlight = attempt
return attempt
}
for (const repo of newlyReferencedRepos.values()) {
resolvedRepoIds.add(repo.id)
const worktrees = await listRepoWorktrees(repo)
for (const wt of worktrees) {
liveLocalWorktreeIds.add(`${repo.id}::${wt.path}`)
}
}
async function hydrateLocalPtyRegistry(
store: HydrationStore,
signal: AbortSignal
): Promise<boolean> {
throwIfSignalAborted(signal)
const provider = getDaemonProvider()
if (!provider) {
return false
}
sessionInfos = await collectSessionInfos(provider)
alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
}
for (const info of sessionInfos) {
// Why: pty:spawn is the authoritative pid writer; don't overwrite its entry with a stale listSessions() pid.
const repoCatalog = getLocalRepoCatalog(store.getRepos())
const reposById = repoCatalog.byId
const verifiedFolderWorktreeIds = getVerifiedFolderWorktreeIds(store, repoCatalog)
const liveGitWorktreeIdsByKey = new Map<string, string | null>()
const resolvedRepoIds = new Set<string>()
let inventory = await collectSessionInfos(provider, signal)
let complete = inventory.complete
let alreadyRegistered = new Set(listRegisteredPtys().map((pty) => pty.ptyId))
for (;;) {
const referencedRepos = new Map<string, Repo>()
for (const info of inventory.sessions) {
if (alreadyRegistered.has(info.sessionId)) {
continue
}
const { worktreeId } = parsePtySessionId(info.sessionId)
if (!worktreeId) {
const parsed = splitWorktreeId(parsePtySessionId(info.sessionId).worktreeId ?? '')
if (!parsed || resolvedRepoIds.has(parsed.repoId)) {
continue
}
// Why: register only proven-local worktrees, matching the spawn-time gate.
if (!liveLocalWorktreeIds.has(worktreeId)) {
const repo = reposById.get(parsed.repoId)
if (!repo || isFolderRepo(repo)) {
resolvedRepoIds.add(parsed.repoId)
continue
}
registerPty({
ptyId: info.sessionId,
worktreeId,
sessionId: info.sessionId,
paneKey: null,
pid:
typeof info.pid === 'number' && Number.isFinite(info.pid) && info.pid > 0
? info.pid
: null
})
referencedRepos.set(repo.id, repo)
}
} catch (err) {
console.warn(
'[memory] Boot-time pty-registry hydration failed:',
err instanceof Error ? err.message : String(err)
if (referencedRepos.size === 0) {
break
}
for (const repo of referencedRepos.values()) {
resolvedRepoIds.add(repo.id)
}
const worktreeResults = await mapSettledWithConcurrency(
[...referencedRepos.values()],
LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY,
async (repo) => {
throwIfSignalAborted(signal)
const worktrees = await waitForPromiseWithSignal(
listLocalRepoWorktreesStrict(repo, {
...getLocalProjectWorktreeGitOptions(store, repo),
signal
}),
signal
)
throwIfSignalAborted(signal)
return { repo, worktrees }
}
)
throwIfSignalAborted(signal)
for (const result of worktreeResults) {
if (result.status === 'rejected') {
complete = false
console.warn(
'[memory] Worktree enumeration failed during pty-registry hydration:',
result.reason instanceof Error ? result.reason.message : String(result.reason)
)
continue
}
const { repo, worktrees } = result.value
for (const worktree of worktrees) {
const worktreeId = `${repo.id}::${worktree.path}`
const key = worktreeIdComparisonKey(worktreeId)
if (key) {
const existing = liveGitWorktreeIdsByKey.get(key)
liveGitWorktreeIdsByKey.set(
key,
existing === undefined || existing === worktreeId ? worktreeId : null
)
}
}
}
inventory = await collectSessionInfos(provider, signal)
complete = complete && inventory.complete
alreadyRegistered = new Set(listRegisteredPtys().map((pty) => pty.ptyId))
}
throwIfSignalAborted(signal)
for (const info of inventory.sessions) {
throwIfSignalAborted(signal)
if (alreadyRegistered.has(info.sessionId)) {
continue
}
const { worktreeId } = parsePtySessionId(info.sessionId)
if (!worktreeId || !isVerifiedLocalWorktree(worktreeId)) {
continue
}
registerPty({
ptyId: info.sessionId,
worktreeId,
sessionId: info.sessionId,
paneKey: null,
pid:
typeof info.pid === 'number' && Number.isFinite(info.pid) && info.pid > 0 ? info.pid : null
})
}
return complete
function isVerifiedLocalWorktree(worktreeId: string): boolean {
if (verifiedFolderWorktreeIds.has(worktreeId)) {
return true
}
const key = worktreeIdComparisonKey(worktreeId)
return key !== null && typeof liveGitWorktreeIdsByKey.get(key) === 'string'
}
}
async function collectSessionInfos(
provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider
): Promise<SessionInfo[]> {
// Why: fan listSessions across current + legacy adapters so no daemon protocol version is missed.
const adapters: readonly DaemonPtyAdapter[] =
provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider
? provider.getAllAdapters()
: [provider]
const out: SessionInfo[] = []
for (const adapter of adapters) {
try {
const sessions = await adapter.listSessions()
// Why: session count can exceed the JS argument limit, so avoid push(...sessions).
for (const session of sessions) {
out.push(session)
}
} catch (err) {
// Why: one adapter's socket being unreachable is normal; don't abort the others.
console.warn(
'[memory] listSessions failed for one adapter during hydration:',
err instanceof Error ? err.message : String(err)
)
function getLocalRepoCatalog(repos: Repo[]): LocalRepoCatalog {
const byId = new Map<string, Repo>()
const ownerCountById = new Map<string, number>()
const ambiguousIds = new Set<string>()
for (const repo of repos) {
ownerCountById.set(repo.id, (ownerCountById.get(repo.id) ?? 0) + 1)
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
continue
}
if (byId.has(repo.id)) {
ambiguousIds.add(repo.id)
} else {
byId.set(repo.id, repo)
}
}
return out
for (const repoId of ambiguousIds) {
byId.delete(repoId)
}
return { byId, ownerCountById }
}
function getVerifiedFolderWorktreeIds(
store: HydrationStore,
repoCatalog: LocalRepoCatalog
): Set<string> {
const verified = new Set<string>()
const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID)
for (const [worktreeId, meta] of Object.entries(metadata)) {
const parsed = splitWorktreeId(worktreeId)
const repo = parsed ? repoCatalog.byId.get(parsed.repoId) : undefined
const hasLocalAuthority =
meta.hostId === LOCAL_EXECUTION_HOST_ID ||
(meta.hostId === undefined && repoCatalog.ownerCountById.get(parsed?.repoId ?? '') === 1)
if (
repo &&
isFolderRepo(repo) &&
hasLocalAuthority &&
isFolderWorkspaceIdForRepo(repo, worktreeId)
) {
verified.add(worktreeId)
}
}
return verified
}
async function collectSessionInfos(
provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider,
signal: AbortSignal
): Promise<DaemonInventory> {
const adapters =
'getAllAdapters' in provider && typeof provider.getAllAdapters === 'function'
? provider.getAllAdapters()
: [provider]
const results = await Promise.all(
adapters.map(async (adapter) => {
try {
throwIfSignalAborted(signal)
const sessions = await waitForPromiseWithSignal<SessionInfo[]>(
adapter.listSessions(),
signal
)
throwIfSignalAborted(signal)
return { complete: true, sessions }
} catch (error) {
throwIfSignalAborted(signal)
console.warn(
'[memory] listSessions failed for one adapter during hydration:',
error instanceof Error ? error.message : String(error)
)
return { complete: false, sessions: [] }
}
})
)
const sessions: SessionInfo[] = []
for (const result of results) {
for (const session of result.sessions) {
sessions.push(session)
}
}
return {
complete: results.length > 0 && results.every((result) => result.complete),
sessions
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ async function startOrcadRuntime(
// Codex-home and Claude-auth preparation are left unset: both are desktop account
// flows. A launch that needs one fails with its own message rather than silently
// spawning an unauthenticated agent.
registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store)
await registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store)
// Why: same post-registration reconciliation `--serve` performs. Skipping it leaves
// restored orchestration rows claiming an authority this host never took over.
+9 -2
View File
@@ -16,6 +16,10 @@ import {
makeProjectHostSetup
} from './persistence-test-harness'
import { TEST_LEAF_1 } from './persistence-session-fixtures'
import {
getLocalWorktreeScanGeneration,
isLocalWorktreeScanGenerationCurrent
} from './local-worktree-scan-generation'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -304,12 +308,13 @@ describe('Store', () => {
it('updates and persists a project Windows runtime preference', async () => {
const project = makeProject({
id: 'project-1',
id: 'repo:r1',
sourceRepoIds: ['r1'],
localWindowsRuntimePreference: { kind: 'inherit-global' }
})
writeDataFile({
...getDefaultPersistedState(testState.dir),
repos: [makeRepo({ id: 'r1' })],
projects: [project],
projectHostSetups: [
makeProjectHostSetup({
@@ -320,12 +325,14 @@ describe('Store', () => {
]
})
const store = await createStore()
const scanGeneration = getLocalWorktreeScanGeneration('r1')
const updated = store.updateProject('project-1', {
const updated = store.updateProject(project.id, {
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(updated?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
expect(isLocalWorktreeScanGenerationCurrent('r1', scanGeneration)).toBe(false)
store.flush()
const reloaded = await createStore()
expect(reloaded.getProjects()[0]?.localWindowsRuntimePreference).toEqual({
@@ -2,14 +2,22 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'v
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getDefaultWorkspaceSession } from '../shared/constants'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants'
import type { PersistedState } from '../shared/persisted-state-types'
import type * as StartupDiagnosticsModule from './startup/startup-diagnostics'
import type { Store as PersistenceStore } from './persistence/loading-store/store'
import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness'
import {
createStore,
dataFile,
makeRepo,
testState,
writeDataFile
} from './persistence-test-harness'
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
const { trackMock, getCohortAtEmitMock, logStartupDiagnosticMock } = vi.hoisted(() => ({
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 }))
getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })),
logStartupDiagnosticMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -33,6 +41,10 @@ vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(() => ({ hosts: [] })),
sshConfigHostsToTargets: vi.fn(() => [])
}))
vi.mock('./startup/startup-diagnostics', async (importOriginal) => {
const actual = await importOriginal<typeof StartupDiagnosticsModule>()
return { ...actual, logStartupDiagnostic: logStartupDiagnosticMock }
})
describe('loading Store extraction seams', () => {
beforeEach(() => {
@@ -40,9 +52,70 @@ describe('loading Store extraction seams', () => {
})
afterEach(() => {
vi.unstubAllEnvs()
logStartupDiagnosticMock.mockReset()
rmSync(testState.dir, { recursive: true, force: true })
})
it('does not serialize the workspace session when startup diagnostics are disabled', () => {
const sentinel = 'startup-diagnostics-workspace-session-sentinel-disabled'
vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '')
const state = getDefaultPersistedState(testState.dir)
state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel }
writeDataFile(state)
const stringifySpy = vi.spyOn(JSON, 'stringify')
const store = createStore()
store.freezeWrites()
const workspaceSessionStringifyCalls = stringifySpy.mock.calls.filter(
([value]) =>
value &&
typeof value === 'object' &&
(value as { activeTabId?: unknown }).activeTabId === sentinel
)
stringifySpy.mockRestore()
expect(store.getWorkspaceSession().activeTabId).toBe(sentinel)
expect(workspaceSessionStringifyCalls).toHaveLength(0)
expect(
logStartupDiagnosticMock.mock.calls.some(([event]) => event === 'persistence-load-done')
).toBe(false)
})
it('reports the unchanged workspace-session byte count when startup diagnostics are enabled', () => {
const sentinel = 'startup-diagnostics-workspace-session-sentinel-enabled'
vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '1')
const state = getDefaultPersistedState(testState.dir)
state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel }
writeDataFile(state)
const stringifySpy = vi.spyOn(JSON, 'stringify')
const store = createStore()
store.freezeWrites()
const workspaceSessionStringifyCalls = stringifySpy.mock.calls.filter(
([value]) =>
value &&
typeof value === 'object' &&
(value as { activeTabId?: unknown }).activeTabId === sentinel
)
stringifySpy.mockRestore()
expect(store.getWorkspaceSession().activeTabId).toBe(sentinel)
expect(workspaceSessionStringifyCalls).toHaveLength(1)
const loadDoneCall = logStartupDiagnosticMock.mock.calls.find(
([event]) => event === 'persistence-load-done'
)
expect(loadDoneCall).toBeDefined()
const details = loadDoneCall?.[1] as Record<string, unknown> | undefined
expect(details).toEqual({
t: expect.any(Number),
repos: state.repos.length,
workspaceSessionBytes: Buffer.byteLength(JSON.stringify(store.getWorkspaceSession()))
})
})
it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => {
mkdirSync(testState.dir, { recursive: true })
writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8')
@@ -507,6 +507,7 @@ describe('Store', () => {
)
})
// ~9s locally; the 30s default timeout leaves too little headroom on a loaded CI shard.
it('loads legacy pane aliases from very large persisted split layouts', async () => {
const leafCount = 130_000
writeDataFile({
@@ -561,7 +562,7 @@ describe('Store', () => {
(entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === `tab1:${leafCount}`
)
).toBe(true)
})
}, 120_000)
it('converts unambiguous dev migration rows into persisted aliases', async () => {
const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1)
@@ -14,6 +14,10 @@ import {
makeTerminalTab,
makeWorktreeLineage
} from './persistence-test-harness'
import {
getLocalWorktreeScanGeneration,
isLocalWorktreeScanGenerationCurrent
} from './local-worktree-scan-generation'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -79,6 +83,23 @@ describe('Store', () => {
expect(fetched!.gitUsername).toBe('')
})
it('invalidates local scans across add, remove, and same-id re-add', async () => {
const store = await createStore()
const repoId = 'scan-lifecycle'
const beforeAdd = getLocalWorktreeScanGeneration(repoId)
store.addRepo(makeRepo({ id: repoId }))
expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeAdd)).toBe(false)
const beforeRemove = getLocalWorktreeScanGeneration(repoId)
store.removeProject(repoId)
expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeRemove)).toBe(false)
const beforeReAdd = getLocalWorktreeScanGeneration(repoId)
store.addRepo(makeRepo({ id: repoId, path: '/replacement' }))
expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeReAdd)).toBe(false)
})
it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => {
const store = await createStore()
store.addRepo(makeRepo())
@@ -369,6 +390,24 @@ describe('Store', () => {
// ── 6b. removeProjectForHost is host-scoped ───────────────────────────
it('invalidates local scans when one host registration is removed', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' }))
store.addRepo(
makeRepo({
id: 'shared',
path: '/remote/repo',
connectionId: 'ssh-old',
executionHostId: 'ssh:ssh-old'
})
)
const beforeRemove = getLocalWorktreeScanGeneration('shared')
store.removeProjectForHost('shared', 'ssh:ssh-old')
expect(isLocalWorktreeScanGenerationCurrent('shared', beforeRemove)).toBe(false)
})
it('removeProjectForHost removes only the target host row for a shared repo id', async () => {
const store = await createStore()
// Same repo id on both local and an SSH host.
@@ -14,6 +14,10 @@ import {
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
import {
getLocalWorktreeScanGeneration,
isLocalWorktreeScanGenerationCurrent
} from './local-worktree-scan-generation'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -227,6 +231,21 @@ describe('Store', () => {
expect(listener).not.toHaveBeenCalled()
})
it('invalidates local worktree scans when the global Windows runtime changes', async () => {
writeDataFile({ repos: [makeRepo()] })
const store = await createStore()
const generation = getLocalWorktreeScanGeneration('r1')
store.updateSettings({ theme: 'dark' })
expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(true)
store.updateSettings({
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(false)
})
it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => {
writeDataFile({ settings: {} })
+23
View File
@@ -11,6 +11,10 @@ import {
makeProject,
makeProjectHostSetup
} from './persistence-test-harness'
import {
getLocalWorktreeScanGeneration,
isLocalWorktreeScanGenerationCurrent
} from './local-worktree-scan-generation'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -75,6 +79,25 @@ describe('Store', () => {
expect(store.getRepo('r1')!.displayName).toBe('renamed')
})
it('invalidates local worktree scans across a git-folder-git update round trip', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'scan-round-trip', kind: 'git' }))
const initialGeneration = getLocalWorktreeScanGeneration('scan-round-trip')
expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(true)
const folder = store.updateRepo('scan-round-trip', { kind: 'folder' })
expect(folder?.kind).toBe('folder')
expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(false)
const folderGeneration = getLocalWorktreeScanGeneration('scan-round-trip')
expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(true)
const git = store.updateRepo('scan-round-trip', { kind: 'git' })
expect(git?.kind).toBe('git')
expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(false)
})
it('updateRepo targets one host when repo ids collide', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' }))
@@ -41,6 +41,7 @@ import {
export type SettingsMutationOperations = {
state: PersistedState
bumpLocalWorktreeScanGeneration: (repoId: string) => void
removeRetainedBlob: (
slot: Parameters<ProtectedSecretPersistence['removeRetainedBlob']>[0]
) => void
@@ -242,6 +243,16 @@ export function updateSettings(
}),
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
if (
!Object.is(
previousSettings.localWindowsRuntimeDefault,
operations.state.settings.localWindowsRuntimeDefault
)
) {
for (const repoId of new Set(operations.state.repos.map(({ id }) => id))) {
operations.bumpLocalWorktreeScanGeneration(repoId)
}
}
operations.scheduleSave()
const changedUpdates = {} as Partial<GlobalSettings> & Record<string, unknown>
for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) {
@@ -42,13 +42,17 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
import { normalizeLoadedProfileState } from './normalize-loaded-profile-state'
type PersistenceStartupDetails = Record<string, unknown> | (() => Record<string, unknown>)
function logPersistenceStartupMilestone(
event: string,
details: Record<string, unknown> = {}
details: PersistenceStartupDetails = {}
): void {
if (isStartupDiagnosticsEnabled()) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
if (!isStartupDiagnosticsEnabled()) {
return
}
const resolvedDetails = typeof details === 'function' ? details() : details
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...resolvedDetails })
}
import type { StoreRuntimeState } from './store-runtime-state'
@@ -279,10 +283,10 @@ export class LoadedStateParsingOperations {
migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache
}
logPersistenceStartupMilestone('persistence-load-done', {
logPersistenceStartupMilestone('persistence-load-done', () => ({
repos: migrated.repos.length,
workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession))
})
}))
return migrated
}
}
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import type { Repo } from '../../../shared/repo-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type * as WriteSchedulingModule from './write-scheduling'
import { MetadataLineageOperations } from './metadata-lineage-operations'
const { scheduleSaveMock } = vi.hoisted(() => ({ scheduleSaveMock: vi.fn() }))
vi.mock('./write-scheduling', async (importOriginal) => ({
...(await importOriginal<typeof WriteSchedulingModule>()),
scheduleSave: scheduleSaveMock
}))
const REPO: Repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
function makeMeta(worktreeId: string): WorktreeMeta {
return {
instanceId: `instance-${worktreeId}`,
hostId: 'local',
displayName: worktreeId,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
describe('MetadataLineageOperations batch metadata pruning', () => {
it('schedules one save for thousands of removals and none for a no-op retry', () => {
const state = getDefaultPersistedState('/home/test')
state.repos = [REPO]
const staleIds = Array.from(
{ length: 2_709 },
(_, index) => `${REPO.id}::/workspace/stale-${index}`
)
for (const worktreeId of staleIds) {
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
}
const operations = new MetadataLineageOperations({ state } as never, {} as never, {} as never)
const scan = operations.captureNativeLocalWorktreeMetadataScanExpectation(REPO)
expect(
operations.pruneSessionlessMissingLocalWorktreeMetadataForRepo(scan, scan.metadata)
).toHaveLength(staleIds.length)
expect(scheduleSaveMock).toHaveBeenCalledTimes(1)
expect(
operations.pruneSessionlessMissingLocalWorktreeMetadataForRepo(scan, scan.metadata)
).toEqual([])
expect(scheduleSaveMock).toHaveBeenCalledTimes(1)
})
})
@@ -1,6 +1,7 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import type { Repo } from '../../../shared/repo-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner'
@@ -28,6 +29,12 @@ import {
setWorktreeMetaForHost as setWorktreeMetaForHostOperation
} from './worktree-identity-metadata'
import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization'
import {
captureNativeLocalWorktreeMetadataScanExpectation as captureNativeLocalWorktreeMetadataScanExpectationOperation,
pruneSessionlessMissingLocalWorktreeMetadataForRepo as pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation,
type LocalWorktreeMetadataPruneExpectation,
type NativeLocalWorktreeMetadataScanExpectation
} from '../tracking-repos/missing-local-worktree-metadata-pruning'
type MetadataLineageOperationsRuntime = Pick<StoreRuntimeState, 'state'>
@@ -89,6 +96,15 @@ export class MetadataLineageOperations {
)
}
captureNativeLocalWorktreeMetadataScanExpectation(
repo: Repo
): NativeLocalWorktreeMetadataScanExpectation {
return captureNativeLocalWorktreeMetadataScanExpectationOperation(
this[metadataLineageOperationsContext].runtime.state,
repo
)
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const state = this[metadataLineageOperationsContext].runtime.state
const stored = state.worktreeMeta[worktreeId]
@@ -184,6 +200,21 @@ export class MetadataLineageOperations {
scheduleSave(this[metadataLineageOperationsContext].scheduling)
}
pruneSessionlessMissingLocalWorktreeMetadataForRepo(
scan: NativeLocalWorktreeMetadataScanExpectation,
missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[]
): string[] {
const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation(
this[metadataLineageOperationsContext].runtime.state,
scan,
missingMetadata
)
if (removed.length > 0) {
scheduleSave(this[metadataLineageOperationsContext].scheduling)
}
return removed
}
getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined {
return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId]
}
@@ -17,6 +17,7 @@ import {
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
type ProfilePreferencesRuntime = Pick<
StoreRuntimeState,
@@ -155,6 +156,7 @@ export function getSettingsMutationOperations(
): SettingsMutationOperations {
return {
state: owner[profilePreferencesContext].runtime.state,
bumpLocalWorktreeScanGeneration,
removeRetainedBlob: (slot) =>
owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot),
scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling),
@@ -28,6 +28,7 @@ import {
} from './repo-lifecycle-operations'
import { scheduleSave } from './write-scheduling'
import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
type ProjectCollectionOperationsRuntime = Pick<
StoreRuntimeState,
@@ -159,6 +160,7 @@ export function getProjectHostOperations(
new ProjectHostPersistenceOperations({
state: owner[projectCollectionOperationsContext].runtime.state,
gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache,
bumpLocalWorktreeScanGeneration,
hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo),
updateRepoBackedProjectHostSetup: (setup, repo, updates) =>
updateRepoBackedProjectHostSetup(
@@ -15,6 +15,7 @@ import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
@@ -43,6 +44,7 @@ export class RepoLifecycleOperations {
}
addRepo(repo: Repo): void {
bumpLocalWorktreeScanGeneration(repo.id)
getRepoOrderOperations(this).addRepo(repo)
}
@@ -55,9 +57,15 @@ export class RepoLifecycleOperations {
}
removeProject(id: string): void {
const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some(
(repo) => repo.id === id
)
this[repoLifecycleOperationsContext].runtime.state.repos = this[
repoLifecycleOperationsContext
].runtime.state.repos.filter((r) => r.id !== id)
if (repoRemoved) {
bumpLocalWorktreeScanGeneration(id)
}
syncProjectHostSetupCompatibilityState(this)
// Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it.
delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id]
@@ -77,9 +85,15 @@ export class RepoLifecycleOperations {
}
removeProjectForHost(id: string, hostId: ExecutionHostId): void {
const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some(
(repo) => repo.id === id && getRepoExecutionHostId(repo) === hostId
)
this[repoLifecycleOperationsContext].runtime.state.repos = this[
repoLifecycleOperationsContext
].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId))
if (repoRemoved) {
bumpLocalWorktreeScanGeneration(id)
}
const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some(
(r) => r.id === id
)
@@ -204,6 +218,7 @@ export function getRepoUpdateOperations(
owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??=
new RepoUpdatePersistenceOperations({
state: owner[repoLifecycleOperationsContext].runtime.state,
bumpLocalWorktreeScanGeneration,
syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner),
scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling),
hydrateRepo: (repo) => hydrateRepo(owner, repo)
@@ -36,11 +36,18 @@ function resolveAliasIdentityKey(state: PersistedState, alias: string): string |
}
/** Drop identity metadata no alias points at any more. */
export function pruneUnreferencedWorktreeIdentityMeta(state: PersistedState): boolean {
export function pruneUnreferencedWorktreeIdentityMeta(
state: PersistedState,
candidates?: ReadonlySet<string>
): boolean {
const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat())
let changed = false
for (const identityKey of Object.keys(state.worktreeMetaByIdentity ?? {})) {
if (!referenced.has(identityKey)) {
const identityKeys = candidates ?? Object.keys(state.worktreeMetaByIdentity ?? {})
for (const identityKey of identityKeys) {
if (
Object.hasOwn(state.worktreeMetaByIdentity ?? {}, identityKey) &&
!referenced.has(identityKey)
) {
delete state.worktreeMetaByIdentity?.[identityKey]
changed = true
}
@@ -1,5 +1,5 @@
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
import { agentHookServer } from '../../agent-hooks/server'
@@ -17,23 +17,69 @@ export function findWorktreeIdForTab(
return undefined
}
export type TerminalTabLookup = {
get(tabId: string): TerminalTab | undefined
}
/** Resolves tabs on demand while retaining first-match ordering and scan progress. */
export function createLazyTerminalTabLookup(session: WorkspaceSessionState): TerminalTabLookup {
const tabsByWorktree = Object.entries(session.tabsByWorktree ?? {})
const tabsById = new Map<string, TerminalTab>()
let worktreeIndex = 0
let tabIndex = 0
let exhausted = false
return {
get(requestedTabId: string): TerminalTab | undefined {
if (tabsById.has(requestedTabId)) {
return tabsById.get(requestedTabId)
}
if (exhausted) {
return undefined
}
while (worktreeIndex < tabsByWorktree.length) {
const tabs = tabsByWorktree[worktreeIndex][1]
while (tabIndex < tabs.length) {
const currentIndex = tabIndex
tabIndex += 1
// Array#some, used by the prior lookup, skips sparse holes.
if (!(currentIndex in tabs)) {
continue
}
const tab = tabs[currentIndex]
const tabId = tab.id
// Preserve first-match behavior when persisted IDs collide.
if (!tabsById.has(tabId)) {
tabsById.set(tabId, tab)
}
if (tabId === requestedTabId) {
return tabsById.get(requestedTabId)
}
}
worktreeIndex += 1
tabIndex = 0
}
exhausted = true
return undefined
}
}
}
/** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */
export function registerLegacyPaneKeyAliasesForTab(args: {
session: WorkspaceSessionState
tabId: string
tab: TerminalTab | undefined
inputLayout: TerminalLayoutSnapshot
normalizedLayout: TerminalLayoutSnapshot
leafIdByInputLeafId: Map<string, string>
}): LegacyPaneKeyAliasEntry[] {
const worktreeId = findWorktreeIdForTab(args.session, args.tabId)
const tab = worktreeId
? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId)
: undefined
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const registeredLegacyPaneKeys = new Set<string>()
const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0
const fallbackPtyId =
!hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined
!hasLeafPtyBindings && typeof args.tab?.ptyId === 'string' ? args.tab.ptyId : undefined
const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => {
if (!isTerminalLeafId(leafId)) {
return false
@@ -80,7 +126,7 @@ export function registerLegacyPaneKeyAliasesForTab(args: {
)
}
}
if (tab?.ptyId && !hasLeafPtyBindings) {
if (args.tab?.ptyId && !hasLeafPtyBindings) {
const fallbackLeafId =
args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root)
let paneKey: string | undefined
@@ -96,9 +142,9 @@ export function registerLegacyPaneKeyAliasesForTab(args: {
if (registeredLegacyPaneKeys.has(legacyPaneKey)) {
continue
}
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId)
agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, args.tab.ptyId)
legacyPaneKeyAliasEntries.push({
ptyId: tab.ptyId,
ptyId: args.tab.ptyId,
legacyPaneKey,
stablePaneKey: paneKey,
updatedAt: Date.now()
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
collectPersistedSessionWorktreeOwners,
collectWorkspaceSessionWorktreeOwners
} from './session-worktree-ownership'
const TARGET = 'repo-1::/workspace/target'
const OTHER = 'repo-1::/workspace/other'
function sessionWith(field: keyof WorkspaceSessionState, value: unknown): WorkspaceSessionState {
return { ...getDefaultWorkspaceSession(), [field]: value } as WorkspaceSessionState
}
function collected(session: WorkspaceSessionState): string[] {
return [...collectWorkspaceSessionWorktreeOwners(session, new Set([TARGET]))]
}
const OWNER_KEYED_FIELD_VALUES = [
['tabsByWorktree', []],
['openFilesByWorktree', []],
['activeFileIdByWorktree', null],
['browserTabsByWorktree', []],
['activeBrowserTabIdByWorktree', null],
['clientHostedBrowserPagesByWorktree', []],
['activeTabTypeByWorktree', 'terminal'],
['activeTabIdByWorktree', null],
['unifiedTabs', []],
['tabGroups', []],
['tabGroupLayouts', { type: 'leaf', groupId: 'group-1' }],
['activeGroupIdByWorktree', 'group-1'],
['lastVisitedAtByWorktreeId', 0],
['defaultTerminalTabsAppliedByWorktreeId', true]
] as const satisfies readonly [keyof WorkspaceSessionState, unknown][]
describe('workspace session worktree ownership', () => {
it.each(OWNER_KEYED_FIELD_VALUES)(
'treats a key in %s as ownership even when its bucket is empty or null',
(field, value) => {
expect(collected(sessionWith(field, { [TARGET]: value }))).toEqual([TARGET])
}
)
it.each([
['active worktree', sessionWith('activeWorktreeId', TARGET)],
['active workspace', sessionWith('activeWorkspaceKey', worktreeWorkspaceKey(TARGET))],
['shutdown owner', sessionWith('activeWorktreeIdsOnShutdown', [TARGET])],
[
'host-qualified recency owner',
sessionWith('lastVisitedAtByWorktreeId', { [`ssh:builder|${TARGET}`]: 1 })
]
])('collects the %s', (_label, session) => {
expect(collected(session)).toEqual([TARGET])
})
it.each([
['terminal tab', 'tabsByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }],
['open file', 'openFilesByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }],
['unified tab', 'unifiedTabs', { [OTHER]: [{ worktreeId: TARGET }] }],
['tab group', 'tabGroups', { [OTHER]: [{ worktreeId: TARGET }] }],
['browser workspace', 'browserTabsByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }],
[
'browser workspace document',
'browserTabsByWorktree',
{ [OTHER]: [{ worktreeId: OTHER, docLocation: { worktreeId: TARGET } }] }
],
['browser page', 'browserPagesByWorkspace', { page: [{ worktreeId: TARGET }] }],
[
'browser page document',
'browserPagesByWorkspace',
{ page: [{ worktreeId: OTHER, docLocation: { worktreeId: TARGET } }] }
],
[
'browser close intent',
'clientHostedBrowserCloseIntentsByEnvironment',
{ environment: [{ worktreeId: TARGET }] }
],
['sleeping agent', 'sleepingAgentSessionsByPaneKey', { pane: { worktreeId: TARGET } }],
['terminal tombstone', 'terminalSurfaceTombstonesByPaneKey', { pane: { worktreeId: TARGET } }],
[
'closed-terminal tombstone',
'closedTerminalTabTombstonesByTabId',
{ tab: { worktreeId: TARGET } }
]
] as const)(
'collects a %s value even when its enclosing key names something else',
(_label, field, value) => {
expect(collected(sessionWith(field, value))).toEqual([TARGET])
}
)
it('collects ownership from every persisted host partition', () => {
const state = getDefaultPersistedState('/home/test')
state.workspaceSessionsByHostId = {
'ssh:builder': sessionWith('tabsByWorktree', { [TARGET]: [] }),
'runtime:environment': sessionWith('activeWorktreeIdsOnShutdown', [OTHER])
}
expect(
[...collectPersistedSessionWorktreeOwners(state, new Set([TARGET, OTHER]))].sort()
).toEqual([OTHER, TARGET])
})
it('prefers an exact candidate id over workspace-key prefix parsing', () => {
const prefixedId = 'folder::/workspace/target'
const session = sessionWith('tabsByWorktree', { [prefixedId]: [] })
expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([prefixedId]))]).toEqual([
prefixedId
])
})
it('matches canonically equivalent Unicode worktree owners', () => {
const candidateId = 'repo-1::/workspace/Café'.normalize('NFC')
const ownerId = candidateId.normalize('NFD')
const session = sessionWith('activeWorktreeId', ownerId)
expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]))]).toEqual([
candidateId
])
})
it('matches Windows worktree owners across drive case and slash spelling', () => {
const candidateId = 'repo-1::D:/Agentic/game2'
const session = sessionWith('activeWorktreeId', 'repo-1::d:\\agentic\\game2')
expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]))]).toEqual([
candidateId
])
})
it('matches macOS /private/tmp session owners to /tmp metadata', () => {
const candidateId = 'repo-1::/private/tmp/orca/target'
const session = sessionWith('activeWorktreeId', 'repo-1::/tmp/orca/target')
expect([
...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]), 'darwin')
]).toEqual([candidateId])
})
it('preserves every candidate that shares an owner comparison key', () => {
const nfcId = 'repo-1::/workspace/Café'.normalize('NFC')
const nfdId = nfcId.normalize('NFD')
const session = sessionWith('activeWorktreeId', nfcId)
expect(
[...collectWorkspaceSessionWorktreeOwners(session, new Set([nfcId, nfdId]))].sort()
).toEqual([nfcId, nfdId].sort())
})
it('ignores fields whose keys cannot identify a worktree owner', () => {
const session = {
...getDefaultWorkspaceSession(),
activeRepoId: TARGET,
activeTabId: TARGET,
terminalLayoutsByTabId: { [TARGET]: { root: null } },
remoteSessionIdsByTabId: { [TARGET]: TARGET },
terminalPtyIncarnationsByPaneKey: { [TARGET]: TARGET },
terminalTopologyRevisionByRepoId: { [TARGET]: 1 }
} as unknown as WorkspaceSessionState
expect(collected(session)).toEqual([])
})
})
@@ -0,0 +1,246 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { worktreeRetentionIdComparisonKey } from '../../worktree-retention-path-comparison'
type WorkspaceSessionWorktreeReferenceKind =
| 'none'
| 'direct'
| 'owner-keyed'
| 'owner-keyed-row-arrays'
| 'owner-keyed-browser-row-arrays'
| 'worktree-id-array'
| 'row-record'
| 'row-arrays'
| 'browser-row-arrays'
/** Every persisted session field must state how, if at all, it can name a worktree owner. */
export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = {
activeRepoId: 'none',
activeWorkspaceKey: 'direct',
activeWorkspaceExecutionHostId: 'none',
activeWorktreeId: 'direct',
activeTabId: 'none',
tabsByWorktree: 'owner-keyed-row-arrays',
terminalLayoutsByTabId: 'none',
activeWorktreeIdsOnShutdown: 'worktree-id-array',
openFilesByWorktree: 'owner-keyed-row-arrays',
activeFileIdByWorktree: 'owner-keyed',
markdownFrontmatterVisible: 'none',
browserTabsByWorktree: 'owner-keyed-browser-row-arrays',
browserPagesByWorkspace: 'browser-row-arrays',
activeBrowserTabIdByWorktree: 'owner-keyed',
clientHostedBrowserPagesByWorktree: 'owner-keyed',
clientHostedBrowserCloseIntentsByEnvironment: 'row-arrays',
activeTabTypeByWorktree: 'owner-keyed',
browserUrlHistory: 'none',
activeTabIdByWorktree: 'owner-keyed',
unifiedTabs: 'owner-keyed-row-arrays',
tabGroups: 'owner-keyed-row-arrays',
tabGroupLayouts: 'owner-keyed',
activeGroupIdByWorktree: 'owner-keyed',
activeConnectionIdsAtShutdown: 'none',
remoteSessionIdsByTabId: 'none',
lastVisitedAtByWorktreeId: 'owner-keyed',
defaultTerminalTabsAppliedByWorktreeId: 'owner-keyed',
sleepingAgentSessionsByPaneKey: 'row-record',
terminalPtyIncarnationsByPaneKey: 'none',
terminalTopologyRevisionByRepoId: 'none',
terminalSurfaceTombstonesByPaneKey: 'row-record',
closedTerminalTabTombstonesByTabId: 'row-record'
} as const satisfies Record<keyof WorkspaceSessionState, WorkspaceSessionWorktreeReferenceKind>
type WorktreeRow = { worktreeId?: string | null }
type BrowserWorktreeRow = WorktreeRow & {
docLocation?: { worktreeId?: string | null } | null
}
export type WorktreeOwnerCandidateCollector = Readonly<{
owners: Set<string>
addOwner: (ownerKey: string | null | undefined) => void
}>
export function getPersistedWorktreeOwnerId(ownerKey: string | null | undefined): string | null {
if (!ownerKey) {
return null
}
if (isWorktreeHostIdentity(ownerKey)) {
return getWorktreeIdFromHostIdentity(ownerKey) || null
}
const scope = parseWorkspaceKey(ownerKey)
if (scope?.type === 'folder') {
return null
}
return scope?.type === 'worktree' ? scope.worktreeId : ownerKey
}
export function createWorktreeOwnerCandidateCollector(
candidateIds: ReadonlySet<string>,
platform: NodeJS.Platform = process.platform,
owners: Set<string> = new Set<string>()
): WorktreeOwnerCandidateCollector {
const candidateIdsByComparisonKey = new Map<string, string[]>()
for (const candidateId of candidateIds) {
const comparisonKey = worktreeRetentionIdComparisonKey(candidateId, platform)
if (!comparisonKey) {
continue
}
const matches = candidateIdsByComparisonKey.get(comparisonKey) ?? []
matches.push(candidateId)
candidateIdsByComparisonKey.set(comparisonKey, matches)
}
return {
owners,
addOwner: (ownerKey) => {
const worktreeId =
ownerKey && candidateIds.has(ownerKey) ? ownerKey : getPersistedWorktreeOwnerId(ownerKey)
if (!worktreeId) {
return
}
const comparisonKey = worktreeRetentionIdComparisonKey(worktreeId, platform)
const equivalentCandidates = comparisonKey
? candidateIdsByComparisonKey.get(comparisonKey)
: undefined
if (equivalentCandidates) {
for (const candidateId of equivalentCandidates) {
owners.add(candidateId)
}
} else if (candidateIds.has(worktreeId)) {
owners.add(worktreeId)
}
}
}
}
function collectRecordKeys(
value: unknown,
add: (ownerKey: string | null | undefined) => void
): void {
for (const ownerKey of Object.keys((value ?? {}) as Record<string, unknown>)) {
add(ownerKey)
}
}
function collectWorktreeRows(
rows: unknown,
add: (ownerKey: string | null | undefined) => void
): void {
if (!Array.isArray(rows)) {
return
}
for (const row of rows as WorktreeRow[]) {
add(row?.worktreeId)
}
}
function collectBrowserRows(
rows: unknown,
add: (ownerKey: string | null | undefined) => void
): void {
if (!Array.isArray(rows)) {
return
}
for (const row of rows as BrowserWorktreeRow[]) {
add(row?.worktreeId)
add(row?.docLocation?.worktreeId)
}
}
function collectSessionFieldOwners(
value: unknown,
kind: WorkspaceSessionWorktreeReferenceKind,
add: (ownerKey: string | null | undefined) => void
): void {
switch (kind) {
case 'none':
return
case 'direct':
add(typeof value === 'string' ? value : null)
return
case 'owner-keyed':
collectRecordKeys(value, add)
return
case 'owner-keyed-row-arrays':
case 'owner-keyed-browser-row-arrays':
for (const [ownerKey, rows] of Object.entries((value ?? {}) as Record<string, unknown>)) {
add(ownerKey)
if (kind === 'owner-keyed-browser-row-arrays') {
collectBrowserRows(rows, add)
} else {
collectWorktreeRows(rows, add)
}
}
return
case 'worktree-id-array':
for (const ownerKey of (value ?? []) as string[]) {
add(ownerKey)
}
return
case 'row-record':
for (const row of Object.values((value ?? {}) as Record<string, WorktreeRow>)) {
add(row?.worktreeId)
}
return
case 'row-arrays':
case 'browser-row-arrays':
for (const rows of Object.values((value ?? {}) as Record<string, unknown>)) {
if (kind === 'browser-row-arrays') {
collectBrowserRows(rows, add)
} else {
collectWorktreeRows(rows, add)
}
}
}
}
export function collectWorkspaceSessionWorktreeOwners(
session: WorkspaceSessionState,
candidateIds: ReadonlySet<string>,
platform: NodeJS.Platform = process.platform,
owners: Set<string> = new Set<string>()
): Set<string> {
const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform, owners)
addWorkspaceSessionWorktreeOwners(session, collector)
return owners
}
function addWorkspaceSessionWorktreeOwners(
session: WorkspaceSessionState,
collector: WorktreeOwnerCandidateCollector
): void {
for (const field of Object.keys(
WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND
) as (keyof WorkspaceSessionState)[]) {
collectSessionFieldOwners(
session[field],
WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field],
collector.addOwner
)
}
}
export function addPersistedSessionWorktreeOwners(
state: Pick<PersistedState, 'workspaceSession' | 'workspaceSessionsByHostId'>,
collector: WorktreeOwnerCandidateCollector
): void {
addWorkspaceSessionWorktreeOwners(state.workspaceSession, collector)
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
if (session) {
addWorkspaceSessionWorktreeOwners(session, collector)
}
}
}
export function collectPersistedSessionWorktreeOwners(
state: Pick<PersistedState, 'workspaceSession' | 'workspaceSessionsByHostId'>,
candidateIds: ReadonlySet<string>,
platform: NodeJS.Platform = process.platform
): Set<string> {
const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform)
addPersistedSessionWorktreeOwners(state, collector)
return collector.owners
}
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { createLazyTerminalTabLookup } from './pane-identity-migration'
import { normalizeWorkspaceSessionPaneIdentities } from './workspace-pane-normalization'
const { registerPaneKeyAliasMock } = vi.hoisted(() => ({
registerPaneKeyAliasMock: vi.fn()
}))
vi.mock('../../agent-hooks/server', () => ({
agentHookServer: {
registerPaneKeyAlias: registerPaneKeyAliasMock
}
}))
const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001'
function terminalTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
return {
id,
worktreeId,
ptyId,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function workspaceSession(
tabsByWorktree: Record<string, TerminalTab[]>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
): WorkspaceSessionState {
return {
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree,
terminalLayoutsByTabId
}
}
function legacyLayout(): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId: 'pane:1' },
activeLeafId: 'pane:1',
expandedLeafId: null
}
}
function stableLayout(): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId: STABLE_LEAF_ID },
activeLeafId: STABLE_LEAF_ID,
expandedLeafId: null
}
}
describe('workspace pane normalization tab index', () => {
beforeEach(() => {
registerPaneKeyAliasMock.mockReset()
})
it('keeps the first tab when duplicate ids occur across worktrees', () => {
const result = normalizeWorkspaceSessionPaneIdentities(
workspaceSession(
{
first: [terminalTab('duplicate-tab', 'first', 'first-pty')],
second: [terminalTab('duplicate-tab', 'second', 'second-pty')]
},
{ 'duplicate-tab': legacyLayout() }
)
)
expect(result.legacyPaneKeyAliasEntries).not.toHaveLength(0)
expect(result.legacyPaneKeyAliasEntries.every((entry) => entry.ptyId === 'first-pty')).toBe(
true
)
})
it('keeps missing tab ids without inventing persisted aliases', () => {
const result = normalizeWorkspaceSessionPaneIdentities(
workspaceSession({}, { 'missing-tab': legacyLayout() })
)
expect(result.legacyPaneKeyAliasEntries).toEqual([])
})
it('reads each tab id once regardless of the number of layouts', () => {
const tabCount = 256
let idReads = 0
const tabs = Array.from({ length: tabCount }, (_, index) => {
const id = `tab-${index}`
const tab = terminalTab(id, 'worktree', null)
Object.defineProperty(tab, 'id', {
enumerable: true,
get: () => {
idReads += 1
return id
}
})
return tab
})
const layouts = Object.fromEntries(
Array.from({ length: tabCount }, (_, index) => [`tab-${index}`, stableLayout()])
)
normalizeWorkspaceSessionPaneIdentities(workspaceSession({ worktree: tabs }, layouts))
expect(idReads).toBe(tabCount)
})
it('does not inspect tabs after an early match', () => {
const first = terminalTab('first', 'worktree', 'first-pty')
const later = terminalTab('later', 'worktree', 'later-pty')
Object.defineProperty(later, 'id', {
enumerable: true,
get: () => {
throw new Error('later tab should not be inspected')
}
})
const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: [first, later] }, {}))
expect(lookup.get('first')).toBe(first)
})
it('resumes scanning and makes later misses constant-time', () => {
const reads: string[] = []
const tabs = ['first', 'second', 'third'].map((id) => {
const tab = terminalTab(id, 'worktree', null)
Object.defineProperty(tab, 'id', {
enumerable: true,
get: () => {
reads.push(id)
return id
}
})
return tab
})
const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: tabs }, {}))
expect(lookup.get('first')).toBe(tabs[0])
expect(reads).toEqual(['first'])
expect(lookup.get('second')).toBe(tabs[1])
expect(reads).toEqual(['first', 'second'])
expect(lookup.get('missing')).toBeUndefined()
expect(reads).toEqual(['first', 'second', 'third'])
expect(lookup.get('missing')).toBeUndefined()
expect(reads).toEqual(['first', 'second', 'third'])
})
it('skips sparse tab-array holes', () => {
const tabs: TerminalTab[] = []
tabs.length = 3
tabs[1] = terminalTab('target', 'worktree', null)
const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: tabs }, {}))
expect(lookup.get('target')).toBe(tabs[1])
expect(lookup.get('missing')).toBeUndefined()
})
it('does not read tab ids when every layout is skipped', () => {
const tab = terminalTab('skipped', 'worktree', null)
let idReads = 0
Object.defineProperty(tab, 'id', {
enumerable: true,
get: () => {
idReads += 1
return 'skipped'
}
})
normalizeWorkspaceSessionPaneIdentities(
workspaceSession({ worktree: [tab] }, { skipped: legacyLayout() }),
{},
{ skipAliasTabIds: new Set(['skipped']) }
)
expect(idReads).toBe(0)
})
})
@@ -10,7 +10,10 @@ import {
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids'
import { registerLegacyPaneKeyAliasesForTab } from './pane-identity-migration'
import {
createLazyTerminalTabLookup,
registerLegacyPaneKeyAliasesForTab
} from './pane-identity-migration'
import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization'
import {
legacyMigrationUnsupportedRowsToAliasEntries,
@@ -40,6 +43,7 @@ export function normalizeWorkspaceSessionPaneIdentities(
const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = []
const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = []
const terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
let tabsById: ReturnType<typeof createLazyTerminalTabLookup> | null = null
for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) {
const normalized = normalizeTerminalLayoutSnapshotForPersistence(
layout,
@@ -48,9 +52,10 @@ export function normalizeWorkspaceSessionPaneIdentities(
terminalLayoutsByTabId[tabId] = normalized.snapshot
leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId)
if (!options.skipAliasTabIds?.has(tabId)) {
tabsById ??= createLazyTerminalTabLookup(session)
const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({
session,
tabId,
tab: tabsById.get(tabId),
inputLayout: layout,
normalizedLayout: normalized.snapshot,
leafIdByInputLeafId: normalized.leafIdByInputLeafId
@@ -0,0 +1,106 @@
// Opt in: ORCA_LOCAL_METADATA_PRUNE_BENCH=1 pnpm test src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts
import { performance } from 'node:perf_hooks'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal'
import {
pruneUnreferencedWorktreeIdentityMeta,
removeWorktreeMetadataForHost
} from '../loading-store/worktree-identity-metadata'
import {
captureNativeLocalWorktreeMetadataScanExpectation,
pruneSessionlessMissingLocalWorktreeMetadataForRepo
} from './missing-local-worktree-metadata-pruning'
const describeBench = process.env.ORCA_LOCAL_METADATA_PRUNE_BENCH ? describe : describe.skip
const ROW_COUNT = 2_709
const REPO: Repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
function makeMeta(index: number): WorktreeMeta {
return {
instanceId: `instance-${index}`,
hostId: 'local',
displayName: `stale-${index}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: index,
lastActivityAt: 0
}
}
function makeState(): { state: PersistedState; staleIds: string[] } {
const state = getDefaultPersistedState('/home/test')
state.repos = [REPO]
state.worktreeMetaByIdentity = {}
state.worktreeIdentityAliases = {}
const staleIds = Array.from(
{ length: ROW_COUNT },
(_, index) => `${REPO.id}::/workspace/stale-${index}`
)
for (const [index, worktreeId] of staleIds.entries()) {
const meta = makeMeta(index)
const identityKey = `identity-${index}`
state.worktreeMeta[worktreeId] = meta
state.worktreeMetaByIdentity[identityKey] = meta
state.worktreeIdentityAliases[`local|${worktreeId}`] = [identityKey]
}
return { state, staleIds }
}
describeBench('authoritative local metadata batch pruning', () => {
it('measures the reported 2,709-row sequential and batch shapes', () => {
const legacy = makeState()
const batch = makeState()
const cloneSpy = vi.spyOn(globalThis, 'structuredClone')
const legacyStartedAt = performance.now()
for (const worktreeId of legacy.staleIds) {
removeWorktreeMetadataForHost(legacy.state, worktreeId, undefined)
delete legacy.state.worktreeMeta[worktreeId]
legacy.state.workspaceSession = removeWorkspaceSessionOwner(
legacy.state.workspaceSession,
worktreeId
)!
}
pruneUnreferencedWorktreeIdentityMeta(legacy.state)
const legacyMs = performance.now() - legacyStartedAt
const legacySessionClones = cloneSpy.mock.calls.length
cloneSpy.mockClear()
const batchStartedAt = performance.now()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(batch.state, REPO)
const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepo(
batch.state,
scan,
scan.metadata
)
const batchMs = performance.now() - batchStartedAt
const batchSessionClones = cloneSpy.mock.calls.length
cloneSpy.mockRestore()
expect(removed).toHaveLength(ROW_COUNT)
expect(Object.keys(legacy.state.worktreeMeta)).toHaveLength(0)
expect(Object.keys(batch.state.worktreeMeta)).toHaveLength(0)
expect(legacySessionClones).toBe(ROW_COUNT)
expect(batchSessionClones).toBe(0)
console.log(
`[bench] rows=${ROW_COUNT} metadata+session=${legacyMs.toFixed(2)}ms -> ${batchMs.toFixed(2)}ms; ` +
`gitScans=${ROW_COUNT} -> 1; sessionClones=${legacySessionClones} -> ${batchSessionClones}; ` +
`minimumSaveSchedules=${ROW_COUNT * 2} -> 1`
)
})
})
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Repo } from '../../../shared/repo-types'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import {
captureNativeLocalWorktreeMetadataScanExpectation,
pruneSessionlessMissingLocalWorktreeMetadataForRepo
} from './missing-local-worktree-metadata-pruning'
const REPO_ID = 'repo-1'
const WORKTREE_ID = `${REPO_ID}::/workspace/stale`
const LOCAL_ALIAS = `local|${WORKTREE_ID}`
const IDENTITY_KEY = 'local-identity'
function makeRepo(id = REPO_ID): Repo {
return {
id,
path: `/workspace/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 0
}
}
function makeMeta(worktreeId = WORKTREE_ID): WorktreeMeta {
return {
instanceId: `instance-${worktreeId}`,
hostId: 'local',
displayName: worktreeId,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
function makeState(): PersistedState {
const state = getDefaultPersistedState('/home/test')
state.repos = [makeRepo()]
return state
}
function makeCanonicalOnlyState(): {
state: PersistedState
meta: WorktreeMeta
} {
const state = makeState()
const meta = makeMeta()
state.worktreeMetaByIdentity = { [IDENTITY_KEY]: meta }
state.worktreeIdentityAliases = { [LOCAL_ALIAS]: [IDENTITY_KEY] }
return { state, meta }
}
function pruneCaptured(state: PersistedState): string[] {
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
return pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)
}
describe('local worktree metadata scan expectations', () => {
it('captures canonical-only local metadata and removes its orphaned identity row', () => {
const { state } = makeCanonicalOnlyState()
expect(pruneCaptured(state)).toEqual([WORKTREE_ID])
expect(state.worktreeMeta[WORKTREE_ID]).toBeUndefined()
expect(state.worktreeIdentityAliases?.[LOCAL_ALIAS]).toBeUndefined()
expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeUndefined()
})
it('removes equal legacy and canonical projections after a persistence round trip', () => {
const state = makeState()
const meta = makeMeta()
state.worktreeMeta[WORKTREE_ID] = structuredClone(meta)
state.worktreeMetaByIdentity = { [IDENTITY_KEY]: structuredClone(meta) }
state.worktreeIdentityAliases = { [LOCAL_ALIAS]: [IDENTITY_KEY] }
expect(pruneCaptured(state)).toEqual([WORKTREE_ID])
expect(state.worktreeMeta[WORKTREE_ID]).toBeUndefined()
expect(state.worktreeIdentityAliases[LOCAL_ALIAS]).toBeUndefined()
expect(state.worktreeMetaByIdentity[IDENTITY_KEY]).toBeUndefined()
})
it('does not sweep an unrelated unaliased canonical row during pruning', () => {
const { state, meta } = makeCanonicalOnlyState()
const unrelated = makeMeta(`${REPO_ID}::/workspace/unrelated`)
state.worktreeMetaByIdentity!.unrelated = unrelated
expect(pruneCaptured(state)).toEqual([WORKTREE_ID])
expect(state.worktreeMetaByIdentity).toEqual({ unrelated })
expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).not.toBeDefined()
expect(meta).not.toBe(unrelated)
})
it('allocates expectations only for the scanned repository', () => {
const state = makeState()
const otherRepo = makeRepo('repo-2')
const otherId = `${otherRepo.id}::/workspace/other`
const canonicalId = `${REPO_ID}::/workspace/canonical`
const canonicalKey = 'canonical-key'
state.repos.push(otherRepo)
state.worktreeMeta[WORKTREE_ID] = makeMeta()
state.worktreeMeta[otherId] = makeMeta(otherId)
state.worktreeMetaByIdentity = {
[canonicalKey]: makeMeta(canonicalId),
'other-key': makeMeta(otherId)
}
state.worktreeIdentityAliases = {
[`local|${canonicalId}`]: [canonicalKey],
[`local|${otherId}`]: ['other-key']
}
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
expect(scan.metadata.map(({ worktreeId }) => worktreeId).sort()).toEqual(
[WORKTREE_ID, canonicalId].sort()
)
})
it('preserves metadata when an alias array is replaced with identical contents', () => {
const { state } = makeCanonicalOnlyState()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
state.worktreeIdentityAliases![LOCAL_ALIAS] = [IDENTITY_KEY]
expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual(
[]
)
expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeDefined()
})
it('preserves metadata when the captured alias array is mutated in place', () => {
const { state } = makeCanonicalOnlyState()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
state.worktreeIdentityAliases![LOCAL_ALIAS]!.push('new-identity')
state.worktreeMetaByIdentity!['new-identity'] = makeMeta()
expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual(
[]
)
})
it.each([
[
'replaced',
(state: PersistedState) => {
state.worktreeMetaByIdentity![IDENTITY_KEY] = {
...state.worktreeMetaByIdentity![IDENTITY_KEY]!
}
}
],
[
'host-mutated',
(state: PersistedState) => {
state.worktreeMetaByIdentity![IDENTITY_KEY]!.hostId = 'ssh:builder'
}
],
[
'instance-mutated',
(state: PersistedState) => {
state.worktreeMetaByIdentity![IDENTITY_KEY]!.instanceId = 'new-instance'
}
],
[
'content-mutated',
(state: PersistedState) => {
state.worktreeMetaByIdentity![IDENTITY_KEY]!.comment = 'new comment'
}
]
] as const)('preserves metadata when a canonical row is %s', (_label, mutate) => {
const { state } = makeCanonicalOnlyState()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
mutate(state)
expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual(
[]
)
})
it.each([
[
'added',
(state: PersistedState) => {
state.worktreeIdentityAliases![`ssh:builder|${WORKTREE_ID}`] = [IDENTITY_KEY]
}
],
[
'removed',
(state: PersistedState) => {
delete state.worktreeIdentityAliases![LOCAL_ALIAS]
}
]
] as const)('preserves metadata when an alias is %s during the scan', (_label, mutate) => {
const { state } = makeCanonicalOnlyState()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
mutate(state)
expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual(
[]
)
expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeDefined()
})
it('preserves a legacy row created after a canonical-only scan capture', () => {
const { state } = makeCanonicalOnlyState()
const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!)
state.worktreeMeta[WORKTREE_ID] = makeMeta()
expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual(
[]
)
expect(state.worktreeMeta[WORKTREE_ID]).toBeDefined()
})
it.each([WORKTREE_ID, `bogus|${WORKTREE_ID}`, `ssh:builder|${WORKTREE_ID}`])(
'preserves metadata with an unqualified or foreign alias: %s',
(alias) => {
const state = makeState()
state.worktreeMeta[WORKTREE_ID] = makeMeta()
state.worktreeMetaByIdentity = { [IDENTITY_KEY]: state.worktreeMeta[WORKTREE_ID]! }
state.worktreeIdentityAliases = { [alias]: [IDENTITY_KEY] }
expect(pruneCaptured(state)).toEqual([])
expect(state.worktreeMeta[WORKTREE_ID]).toBeDefined()
}
)
it('preserves an unqualified alias whose worktree path contains a pipe', () => {
const state = makeState()
const pipedId = `${REPO_ID}::/workspace/a|b`
state.worktreeMeta[pipedId] = makeMeta(pipedId)
state.worktreeMetaByIdentity = { [IDENTITY_KEY]: state.worktreeMeta[pipedId]! }
state.worktreeIdentityAliases = { [pipedId]: [IDENTITY_KEY] }
expect(pruneCaptured(state)).toEqual([])
expect(state.worktreeMeta[pipedId]).toBeDefined()
expect(state.worktreeIdentityAliases[pipedId]).toEqual([IDENTITY_KEY])
})
})
@@ -0,0 +1,243 @@
import { isDeepStrictEqual } from 'node:util'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { Project } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { getRepoKind } from '../../../shared/repo-kind'
import { splitWorktreeId } from '../../../shared/worktree/id'
import {
composeWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
type CapturedLocalRepoIdentity = Readonly<
Pick<Repo, 'id' | 'path' | 'connectionId' | 'executionHostId'> & {
kind: ReturnType<typeof getRepoKind>
expectedRepo: Repo | undefined
}
>
type CapturedLocalRoutingIdentity = Readonly<{
expectedProject: Project | undefined
expectedProjectUpdatedAt: number | undefined
expectedSettings: GlobalSettings
}>
type MetadataRowExpectation = Readonly<{
expectedPresent: boolean
expectedMeta: WorktreeMeta | undefined
expectedSerialized: string | null | undefined
expectedInstanceId: string | undefined
expectedHostId: WorktreeMeta['hostId']
}>
type MetadataAliasExpectation = Readonly<{
alias: string
expectedAlias: string[]
expectedIdentityKeys: readonly string[]
expectedIdentityRows: readonly (MetadataRowExpectation & { identityKey: string })[]
}>
export type LocalWorktreeMetadataPruneExpectation = Readonly<{
worktreeId: string
expectedLegacy: MetadataRowExpectation
expectedAliases: readonly MetadataAliasExpectation[]
}>
export type NativeLocalWorktreeMetadataScanExpectation = Readonly<{
repo: CapturedLocalRepoIdentity
routing: CapturedLocalRoutingIdentity
metadata: readonly LocalWorktreeMetadataPruneExpectation[]
}>
type MetadataAliasEntry = readonly [alias: string, identityKeys: string[]]
function serializeMetadataRow(meta: WorktreeMeta | undefined): string | null | undefined {
if (!meta) {
return undefined
}
try {
return JSON.stringify(meta)
} catch {
return null
}
}
function captureRow(
record: Readonly<Record<string, WorktreeMeta>> | undefined,
key: string
): MetadataRowExpectation {
const expectedPresent = Object.hasOwn(record ?? {}, key)
const expectedMeta = record?.[key]
return {
expectedPresent,
expectedMeta,
expectedSerialized: serializeMetadataRow(expectedMeta),
expectedInstanceId: expectedMeta?.instanceId,
expectedHostId: expectedMeta?.hostId
}
}
export function indexMetadataAliasesForWorktreeIds(
state: PersistedState,
worktreeIds: ReadonlySet<string>
): Map<string, MetadataAliasEntry[]> {
const indexed = new Map<string, MetadataAliasEntry[]>()
for (const [alias, identityKeys] of Object.entries(state.worktreeIdentityAliases ?? {})) {
const matches = new Set<string>()
if (worktreeIds.has(alias)) {
matches.add(alias)
}
const separator = alias.indexOf('|')
const suffix = separator === -1 ? alias : alias.slice(separator + 1)
if (worktreeIds.has(suffix)) {
matches.add(suffix)
}
for (const worktreeId of matches) {
const entries = indexed.get(worktreeId) ?? []
entries.push([alias, identityKeys])
indexed.set(worktreeId, entries)
}
}
return indexed
}
export function captureNativeLocalWorktreeMetadataScanExpectation(
state: PersistedState,
repo: Repo
): NativeLocalWorktreeMetadataScanExpectation {
const matchingRepos = state.repos.filter((entry) => entry.id === repo.id)
const expectedRepo = matchingRepos.length === 1 ? matchingRepos[0] : undefined
const expectedProject = state.projects.find((project) => project.sourceRepoIds.includes(repo.id))
const candidateIds = new Set<string>()
for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) {
if (
splitWorktreeId(worktreeId)?.repoId === repo.id &&
(!meta.hostId || meta.hostId === LOCAL_EXECUTION_HOST_ID)
) {
candidateIds.add(worktreeId)
}
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
if (!isWorktreeHostIdentity(alias)) {
continue
}
const worktreeId = getWorktreeIdFromHostIdentity(alias)
if (
alias === composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId) &&
splitWorktreeId(worktreeId)?.repoId === repo.id
) {
candidateIds.add(worktreeId)
}
}
const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds)
return {
repo: {
id: repo.id,
path: repo.path,
connectionId: repo.connectionId,
executionHostId: repo.executionHostId,
kind: getRepoKind(repo),
expectedRepo
},
routing: {
expectedProject,
expectedProjectUpdatedAt: expectedProject?.updatedAt,
expectedSettings: state.settings
},
metadata: [...candidateIds].map((worktreeId) => ({
worktreeId,
expectedLegacy: captureRow(state.worktreeMeta, worktreeId),
expectedAliases: (aliasesByWorktreeId.get(worktreeId) ?? []).map(([alias, identityKeys]) => ({
alias,
expectedAlias: identityKeys,
expectedIdentityKeys: [...identityKeys],
expectedIdentityRows: identityKeys.map((identityKey) => ({
identityKey,
...captureRow(state.worktreeMetaByIdentity, identityKey)
}))
}))
}))
}
}
function rowStillMatches(
record: Readonly<Record<string, WorktreeMeta>> | undefined,
key: string,
expected: MetadataRowExpectation
): boolean {
const current = record?.[key]
return (
Object.hasOwn(record ?? {}, key) === expected.expectedPresent &&
current === expected.expectedMeta &&
expected.expectedSerialized !== null &&
serializeMetadataRow(current) === expected.expectedSerialized &&
current?.instanceId === expected.expectedInstanceId &&
current?.hostId === expected.expectedHostId
)
}
function aliasesStillMatch(
state: PersistedState,
expected: LocalWorktreeMetadataPruneExpectation,
currentAliases: readonly MetadataAliasEntry[]
): boolean {
if (currentAliases.length !== expected.expectedAliases.length) {
return false
}
const currentByAlias = new Map(currentAliases)
return expected.expectedAliases.every((aliasExpectation) => {
const current = currentByAlias.get(aliasExpectation.alias)
return Boolean(
current &&
current === aliasExpectation.expectedAlias &&
current.length === aliasExpectation.expectedIdentityKeys.length &&
current.every((key, index) => key === aliasExpectation.expectedIdentityKeys[index]) &&
aliasExpectation.expectedIdentityRows.every(({ identityKey, ...row }) =>
rowStillMatches(state.worktreeMetaByIdentity, identityKey, row)
)
)
})
}
export function removeRevalidatedLocalWorktreeMetadata(
state: PersistedState,
expected: LocalWorktreeMetadataPruneExpectation,
currentAliases: readonly MetadataAliasEntry[],
removedIdentityKeys?: Set<string>
): boolean {
if (
!rowStillMatches(state.worktreeMeta, expected.worktreeId, expected.expectedLegacy) ||
!aliasesStillMatch(state, expected, currentAliases)
) {
return false
}
const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, expected.worktreeId)
if (currentAliases.some(([alias]) => alias !== localAlias)) {
return false
}
const legacy = state.worktreeMeta[expected.worktreeId]
const identityKeys = state.worktreeIdentityAliases?.[localAlias]
if (identityKeys && identityKeys.length !== 1) {
return false
}
const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined
if (
(legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) ||
(canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) ||
(identityKeys && !canonical) ||
(legacy && canonical && !isDeepStrictEqual(legacy, canonical)) ||
(!legacy && !canonical)
) {
return false
}
if (identityKeys?.[0]) {
removedIdentityKeys?.add(identityKeys[0])
}
delete state.worktreeMeta[expected.worktreeId]
delete state.worktreeIdentityAliases?.[localAlias]
return true
}
@@ -0,0 +1,477 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Project } from '../../../shared/project-types'
import type { Repo } from '../../../shared/repo-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import type { WorktreeMeta } from '../../../shared/worktree/meta-types'
import {
captureNativeLocalWorktreeMetadataScanExpectation,
pruneSessionlessMissingLocalWorktreeMetadataForRepo
} from './missing-local-worktree-metadata-pruning'
const REPO_ID = 'repo-1'
const LIVE_ID = `${REPO_ID}::/workspace/live`
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: REPO_ID,
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
...overrides
}
}
function makeMeta(worktreeId: string): WorktreeMeta {
return {
instanceId: `instance-${worktreeId}`,
hostId: 'local',
displayName: worktreeId,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
function makeState(repo = makeRepo()): PersistedState {
const state = getDefaultPersistedState('/home/test')
state.repos = [repo]
return state
}
function capture(state: PersistedState, repo = state.repos[0]!) {
return captureNativeLocalWorktreeMetadataScanExpectation(state, repo)
}
function pruneCaptured(
state: PersistedState,
scan: ReturnType<typeof capture>,
ids: readonly string[],
platform?: NodeJS.Platform
): string[] {
const wanted = new Set(ids)
return pruneSessionlessMissingLocalWorktreeMetadataForRepo(
state,
scan,
scan.metadata.filter(({ worktreeId }) => wanted.has(worktreeId)),
platform
)
}
describe('pruneSessionlessMissingLocalWorktreeMetadataForRepo', () => {
it('removes 2,709 sessionless rows in one batch without cloning or changing session state', () => {
const state = makeState()
const staleIds = Array.from(
{ length: 2_709 },
(_, index) => `${REPO_ID}::/workspace/stale-${index}`
)
const allIds = [LIVE_ID, ...staleIds]
state.worktreeMetaByIdentity = {}
state.worktreeIdentityAliases = {}
for (const [index, worktreeId] of allIds.entries()) {
const meta = makeMeta(worktreeId)
const identityKey = `identity-${index}`
state.worktreeMeta[worktreeId] = meta
state.worktreeMetaByIdentity[identityKey] = meta
state.worktreeIdentityAliases[`local|${worktreeId}`] = [identityKey]
}
state.worktreeLineageById[staleIds[0]!] = { worktreeId: staleIds[0] } as never
state.workspaceLineageByChildKey[worktreeWorkspaceKey(staleIds[0]!)] = {
childWorkspaceKey: worktreeWorkspaceKey(staleIds[0]!)
} as never
state.workspaceSession.terminalTopologyRevisionByRepoId = { [REPO_ID]: 7 }
const hostSession = {
...getDefaultWorkspaceSession(),
terminalTopologyRevisionByRepoId: { [REPO_ID]: 11 }
}
state.workspaceSessionsByHostId = { 'runtime:mirror': hostSession }
const scan = capture(state)
const session = state.workspaceSession
const sessionSnapshot = structuredClone(session)
const hostSessionSnapshot = structuredClone(hostSession)
const cloneSpy = vi.spyOn(globalThis, 'structuredClone')
const removed = pruneCaptured(state, scan, staleIds)
expect(removed).toHaveLength(staleIds.length)
expect(cloneSpy).not.toHaveBeenCalled()
cloneSpy.mockRestore()
expect(Object.keys(state.worktreeMeta)).toEqual([LIVE_ID])
expect(Object.keys(state.worktreeMetaByIdentity ?? {})).toEqual(['identity-0'])
expect(Object.keys(state.worktreeIdentityAliases ?? {})).toEqual([`local|${LIVE_ID}`])
expect(state.worktreeLineageById[staleIds[0]!]).toBeUndefined()
expect(state.workspaceLineageByChildKey[worktreeWorkspaceKey(staleIds[0]!)]).toBeUndefined()
expect(state.workspaceSession).toBe(session)
expect(state.workspaceSession).toEqual(sessionSnapshot)
expect(state.workspaceSessionsByHostId?.['runtime:mirror']).toBe(hostSession)
expect(state.workspaceSessionsByHostId?.['runtime:mirror']).toEqual(hostSessionSnapshot)
})
it('fails closed when metadata is mutated or replaced after the scan starts', () => {
const inPlaceState = makeState()
const inPlaceId = `${REPO_ID}::/workspace/in-place`
inPlaceState.worktreeMeta[inPlaceId] = makeMeta(inPlaceId)
const inPlaceScan = capture(inPlaceState)
inPlaceState.worktreeMeta[inPlaceId]!.instanceId = 'replacement-instance'
expect(pruneCaptured(inPlaceState, inPlaceScan, [inPlaceId])).toEqual([])
const replacedState = makeState()
const replacedId = `${REPO_ID}::/workspace/replaced`
const original = makeMeta(replacedId)
replacedState.worktreeMeta[replacedId] = original
const replacedScan = capture(replacedState)
replacedState.worktreeMeta[replacedId] = { ...original }
expect(pruneCaptured(replacedState, replacedScan, [replacedId])).toEqual([])
expect(replacedState.worktreeMeta[replacedId]).not.toBe(original)
})
it('fails closed when an identical repo row is removed and re-added during the scan', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/re-added-repo`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
const replacement = { ...state.repos[0]! }
state.repos.splice(0, 1, replacement)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
expect(state.repos[0]).toBe(replacement)
expect(state.worktreeMeta[worktreeId]).toBeDefined()
})
it('uses the effective Git kind when the raw legacy repo omitted kind', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/legacy-git-kind`
const rawRepo = state.repos[0]!
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state, { ...rawRepo, kind: 'git' })
expect(rawRepo.kind).toBeUndefined()
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId])
})
it.each([
'tabsByWorktree',
'browserTabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'clientHostedBrowserPagesByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const)('preserves an owner named by workspaceSession.%s', (field) => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/${field}`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
const ownerKey = field === 'lastVisitedAtByWorktreeId' ? `local|${worktreeId}` : worktreeId
;(state.workspaceSession as unknown as Record<string, unknown>)[field] = {
[ownerKey]: []
}
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
expect(state.worktreeMeta[worktreeId]).toBeDefined()
})
it('preserves scalar, nested, remote-partition, mobile, and relay recovery owners', () => {
const ownerMutations: ((state: PersistedState, worktreeId: string) => void)[] = [
(state, worktreeId) => {
state.workspaceSession.activeWorktreeId = worktreeId
},
(state, worktreeId) => {
state.workspaceSession.activeWorkspaceKey = worktreeWorkspaceKey(worktreeId)
},
(state, worktreeId) => {
state.workspaceSession.activeWorktreeIdsOnShutdown = [worktreeId]
},
(state, worktreeId) => {
state.ui.lastActiveWorktreeId = worktreeId
},
(state, worktreeId) => {
state.workspaceSession.sleepingAgentSessionsByPaneKey = {
pane: { worktreeId } as never
}
},
(state, worktreeId) => {
state.workspaceSession.terminalSurfaceTombstonesByPaneKey = {
pane: { worktreeId } as never
}
},
(state, worktreeId) => {
state.workspaceSession.closedTerminalTabTombstonesByTabId = {
tab: { worktreeId, closedAt: 1 }
}
},
(state, worktreeId) => {
state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment = {
runtime: [{ worktreeId, browserPageId: 'page', closedAt: 1 }]
}
},
(state, worktreeId) => {
state.workspaceSession.browserPagesByWorkspace = {
browser: [{ worktreeId } as never]
}
},
(state, worktreeId) => {
state.workspaceSessionsByHostId = {
'ssh:builder': {
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { [worktreeId]: 1 }
}
}
},
(state, worktreeId) => {
state.mobileClientTabSelectionsByDeviceId = {
phone: { [worktreeId]: {} as never }
}
},
(state, worktreeId) => {
state.sshRemotePtyLeases = [
{
targetId: 'builder',
ptyId: 'pty',
worktreeId,
state: 'detached',
createdAt: 1,
updatedAt: 1
}
]
},
(state, worktreeId) => {
state.migrationUnsupportedPtyEntries = [
{
ptyId: 'pty',
worktreeId,
reason: 'legacy-numeric-pane-key',
source: 'local',
updatedAt: 1
}
]
},
(state, worktreeId) => {
state.automations = [
{
enabled: true,
workspaceMode: 'existing',
workspaceId: worktreeId
} as never
]
},
(state, worktreeId) => {
state.automationRuns = [{ status: 'dispatching', workspaceId: worktreeId } as never]
}
]
for (const [index, mutate] of ownerMutations.entries()) {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/owner-${index}`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
mutate(state, worktreeId)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
}
})
it('preserves canonically equivalent session and top-level owners', () => {
const candidateId = `${REPO_ID}::/workspace/Café`.normalize('NFC')
const ownerId = candidateId.normalize('NFD')
const ownerMutations: ((state: PersistedState) => void)[] = [
(state) => {
state.workspaceSession.activeWorktreeId = ownerId
},
(state) => {
state.ui.lastActiveWorktreeId = ownerId
}
]
for (const mutate of ownerMutations) {
const state = makeState()
state.worktreeMeta[candidateId] = makeMeta(candidateId)
const scan = capture(state)
mutate(state)
expect(pruneCaptured(state, scan, [candidateId])).toEqual([])
expect(state.worktreeMeta[candidateId]).toBeDefined()
}
})
it('removes a row on a later pass after its persisted owner is cleared', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/two-phase`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
state.workspaceSession.lastVisitedAtByWorktreeId = { [worktreeId]: 1 }
const scan = capture(state)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
delete state.workspaceSession.lastVisitedAtByWorktreeId![worktreeId]
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId])
})
it('treats dotfile visibility and cleanup dismissals as non-owning preferences', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/non-owning-preferences`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const showDotfilesByWorktree = { [worktreeId]: true }
const workspaceCleanup = {
dismissals: {
[worktreeId]: {
worktreeId,
dismissedAt: 1,
fingerprint: 'fingerprint',
classifierVersion: 1
}
}
}
state.ui.showDotfilesByWorktree = showDotfilesByWorktree
state.ui.workspaceCleanup = workspaceCleanup
const scan = capture(state)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId])
expect(state.ui.showDotfilesByWorktree).toBe(showDotfilesByWorktree)
expect(state.ui.workspaceCleanup).toBe(workspaceCleanup)
})
it('preserves foreign aliases, ambiguous local aliases, and divergent projections', () => {
const foreignState = makeState()
const foreignId = `${REPO_ID}::/workspace/foreign-alias`
const localMeta = makeMeta(foreignId)
const remoteMeta = { ...localMeta, hostId: 'ssh:builder' as const }
foreignState.worktreeMeta[foreignId] = localMeta
foreignState.worktreeMetaByIdentity = { local: localMeta, remote: remoteMeta }
foreignState.worktreeIdentityAliases = {
[`local|${foreignId}`]: ['local'],
[`ssh:builder|${foreignId}`]: ['remote']
}
const foreignScan = capture(foreignState)
expect(pruneCaptured(foreignState, foreignScan, [foreignId])).toEqual([])
expect(foreignState.worktreeMetaByIdentity).toEqual({ local: localMeta, remote: remoteMeta })
const ambiguousState = makeState()
const ambiguousId = `${REPO_ID}::/workspace/ambiguous`
const first = makeMeta(ambiguousId)
ambiguousState.worktreeMeta[ambiguousId] = first
ambiguousState.worktreeMetaByIdentity = { first, second: { ...first } }
ambiguousState.worktreeIdentityAliases = { [`local|${ambiguousId}`]: ['first', 'second'] }
expect(pruneCaptured(ambiguousState, capture(ambiguousState), [ambiguousId])).toEqual([])
const divergentState = makeState()
const divergentId = `${REPO_ID}::/workspace/divergent`
const legacy = makeMeta(divergentId)
const canonical = { ...legacy, comment: 'newer canonical comment' }
divergentState.worktreeMeta[divergentId] = legacy
divergentState.worktreeMetaByIdentity = { canonical }
divergentState.worktreeIdentityAliases = { [`local|${divergentId}`]: ['canonical'] }
const divergentScan = capture(divergentState)
expect(pruneCaptured(divergentState, divergentScan, [divergentId])).toEqual([])
})
it('fails closed for repo replacement, host collisions, remote, runtime, and folder repos', () => {
const mutations: ((state: PersistedState) => void)[] = [
(state) => {
state.repos[0] = makeRepo({ path: '/workspace/replacement' })
},
(state) => {
state.repos.push(makeRepo({ connectionId: 'builder', executionHostId: 'ssh:builder' }))
},
(state) => {
state.repos[0] = makeRepo({ connectionId: 'builder', executionHostId: 'ssh:builder' })
},
(state) => {
state.repos[0] = makeRepo({ executionHostId: 'runtime:environment' })
},
(state) => {
state.repos[0] = makeRepo({ kind: 'folder' })
}
]
for (const mutate of mutations) {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/stale`
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
mutate(state)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
}
})
it('fails closed when project runtime routing changes away and back during the scan', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/project-routing-race`
const project: Project = {
id: 'project-1',
displayName: 'project',
badgeColor: '#000',
localWindowsRuntimePreference: { kind: 'windows-host' },
sourceRepoIds: [REPO_ID],
createdAt: 1,
updatedAt: 1
}
state.projects = [project]
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
project.localWindowsRuntimePreference = { kind: 'wsl', distro: 'Ubuntu' }
project.updatedAt += 1
project.localWindowsRuntimePreference = { kind: 'windows-host' }
project.updatedAt += 1
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
expect(state.worktreeMeta[worktreeId]).toBeDefined()
})
it('fails closed when global runtime routing changes away and back during the scan', () => {
const state = makeState()
const worktreeId = `${REPO_ID}::/workspace/settings-routing-race`
const originalDefault = state.settings.localWindowsRuntimeDefault
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
const scan = capture(state)
state.settings = {
...state.settings,
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}
state.settings = {
...state.settings,
localWindowsRuntimeDefault: originalDefault
}
expect(state.settings.localWindowsRuntimeDefault).toEqual(originalDefault)
expect(pruneCaptured(state, scan, [worktreeId])).toEqual([])
expect(state.worktreeMeta[worktreeId]).toBeDefined()
})
it('rejects malformed, folder-instance, WSL, and non-native Windows candidate paths', () => {
const state = makeState()
const foreignWindowsId = `${REPO_ID}::C:\\workspace\\stale`
const ids = [
`${REPO_ID}::relative/path`,
`${REPO_ID}::`,
`wrong::/workspace/stale`,
`${REPO_ID}::/workspace/folder::workspace:11111111-1111-4111-8111-111111111111`,
`${REPO_ID}::\\\\wsl.localhost\\Ubuntu\\home\\gone`,
`${REPO_ID}::/home/user/wsl-legacy`,
foreignWindowsId
]
for (const worktreeId of ids) {
state.worktreeMeta[worktreeId] = makeMeta(worktreeId)
}
const scan = capture(state)
expect(pruneCaptured(state, scan, ids.slice(0, -2))).toEqual([])
expect(pruneCaptured(state, scan, [ids.at(-2)!], 'win32')).toEqual([])
expect(pruneCaptured(state, scan, [foreignWindowsId], 'linux')).toEqual([])
})
})
@@ -0,0 +1,153 @@
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getRepoKind } from '../../../shared/repo-kind'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../../shared/worktree/id'
import { isWslUncPath } from '../../../shared/wsl-paths'
import { isFinalAutomationRunStatus } from '../../../shared/automations-types'
import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata'
import {
addPersistedSessionWorktreeOwners,
createWorktreeOwnerCandidateCollector
} from '../restoring-sessions/session-worktree-ownership'
import {
indexMetadataAliasesForWorktreeIds,
removeRevalidatedLocalWorktreeMetadata,
type LocalWorktreeMetadataPruneExpectation,
type NativeLocalWorktreeMetadataScanExpectation
} from './local-worktree-metadata-scan-expectation'
export { captureNativeLocalWorktreeMetadataScanExpectation } from './local-worktree-metadata-scan-expectation'
export type {
LocalWorktreeMetadataPruneExpectation,
NativeLocalWorktreeMetadataScanExpectation
} from './local-worktree-metadata-scan-expectation'
function collectPersistedWorkspaceOwners(
state: PersistedState,
candidateIds: ReadonlySet<string>,
platform: NodeJS.Platform
): Set<string> {
const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform)
addPersistedSessionWorktreeOwners(state, collector)
const add = collector.addOwner
add(state.ui.lastActiveWorktreeId)
for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) {
for (const worktreeId of Object.keys(selections)) {
add(worktreeId)
}
}
for (const lease of state.sshRemotePtyLeases) {
add(lease.worktreeId)
}
for (const entry of state.migrationUnsupportedPtyEntries) {
add(entry.worktreeId)
}
for (const automation of state.automations) {
if (automation.enabled && automation.workspaceMode === 'existing') {
add(automation.workspaceId)
}
}
for (const run of state.automationRuns) {
if (!isFinalAutomationRunStatus(run.status)) {
add(run.workspaceId)
}
}
return collector.owners
}
function repoStillMatches(
state: PersistedState,
expected: NativeLocalWorktreeMetadataScanExpectation['repo']
): boolean {
const owners = state.repos.filter((repo) => repo.id === expected.id)
const current = owners[0]
return Boolean(
owners.length === 1 &&
current &&
current === expected.expectedRepo &&
current.path === expected.path &&
current.connectionId === expected.connectionId &&
current.executionHostId === expected.executionHostId &&
getRepoKind(current) === expected.kind &&
expected.kind === 'git' &&
!current.connectionId &&
getRepoExecutionHostId(current) === LOCAL_EXECUTION_HOST_ID
)
}
function routingStillMatches(
state: PersistedState,
expected: NativeLocalWorktreeMetadataScanExpectation
): boolean {
const currentProject = state.projects.find((project) =>
project.sourceRepoIds.includes(expected.repo.id)
)
return (
currentProject === expected.routing.expectedProject &&
currentProject?.updatedAt === expected.routing.expectedProjectUpdatedAt &&
state.settings === expected.routing.expectedSettings
)
}
function isValidCandidateId(
repoId: string,
worktreeId: string,
platform: NodeJS.Platform
): boolean {
const parsed = splitWorktreeId(worktreeId)
return Boolean(
parsed?.repoId === repoId &&
parsed.worktreePath.length > 0 &&
!isWslUncPath(parsed.worktreePath) &&
(platform === 'win32'
? isWindowsAbsolutePathLike(parsed.worktreePath)
: parsed.worktreePath.startsWith('/')) &&
!worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR)
)
}
export function pruneSessionlessMissingLocalWorktreeMetadataForRepo(
state: PersistedState,
scan: NativeLocalWorktreeMetadataScanExpectation,
missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[],
platform = process.platform
): string[] {
if (
missingMetadata.length === 0 ||
!repoStillMatches(state, scan.repo) ||
!routingStillMatches(state, scan)
) {
return []
}
const candidateIds = new Set(missingMetadata.map(({ worktreeId }) => worktreeId))
const sessionOwners = collectPersistedWorkspaceOwners(state, candidateIds, platform)
const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds)
const removedIdentityKeys = new Set<string>()
const removedIds: string[] = []
for (const expectation of missingMetadata) {
const { worktreeId } = expectation
if (
!isValidCandidateId(scan.repo.id, worktreeId, platform) ||
sessionOwners.has(worktreeId) ||
!removeRevalidatedLocalWorktreeMetadata(
state,
expectation,
aliasesByWorktreeId.get(worktreeId) ?? [],
removedIdentityKeys
)
) {
continue
}
delete state.worktreeLineageById[worktreeId]
delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)]
removedIds.push(worktreeId)
}
if (removedIds.length > 0) {
// Why: a global sweep could delete unrelated unaliased rows from another repo or host.
pruneUnreferencedWorktreeIdentityMeta(state, removedIdentityKeys)
}
return removedIds
}
@@ -19,6 +19,7 @@ import { repoGitUsernameCacheKey } from './repo-hydration'
export type ProjectHostMutationOperations = {
state: PersistedState
gitUsernameCache: Map<string, string>
bumpLocalWorktreeScanGeneration: (repoId: string) => void
hydrateRepo: (repo: Repo) => Repo
updateRepoBackedProjectHostSetup: (
setup: ProjectHostSetup,
@@ -87,6 +88,9 @@ export class ProjectHostPersistenceOperations {
return null
}
if ('localWindowsRuntimePreference' in updates) {
for (const repoId of project.sourceRepoIds) {
this.operations.bumpLocalWorktreeScanGeneration(repoId)
}
if (updates.localWindowsRuntimePreference === undefined) {
delete project.localWindowsRuntimePreference
} else {
@@ -9,6 +9,7 @@ import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization'
export type RepoUpdateMutationOperations = {
state: PersistedState
bumpLocalWorktreeScanGeneration: (repoId: string) => void
syncProjectHostSetupCompatibilityState: () => void
scheduleSave: () => void
hydrateRepo: (repo: Repo) => Repo
@@ -21,6 +22,10 @@ export class RepoUpdatePersistenceOperations {
return this.operations.state
}
private bumpLocalWorktreeScanGeneration(repoId: string): void {
this.operations.bumpLocalWorktreeScanGeneration(repoId)
}
private syncProjectHostSetupCompatibilityState(): void {
this.operations.syncProjectHostSetupCompatibilityState()
}
@@ -177,6 +182,7 @@ export class RepoUpdatePersistenceOperations {
}
}
Object.assign(repo, sanitizedUpdates)
this.bumpLocalWorktreeScanGeneration(id)
this.syncProjectHostSetupCompatibilityState()
this.scheduleSave()
return this.hydrateRepo(repo)
@@ -1,6 +1,16 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshGitProvider } from './ssh-git-provider'
import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness'
import {
createMockMux,
waitForRequestCount,
type MockMultiplexer
} from './ssh-git-provider-test-harness'
function methodNotFound(method: string): Error & { code: number } {
return Object.assign(new Error(`Method not found: ${method}`), { code: -32601 })
}
const CLEAN_STATUS = { entries: [], conflictOperation: 'unknown' }
describe('SshGitProvider', () => {
let mux: MockMultiplexer
@@ -67,6 +77,40 @@ describe('SshGitProvider', () => {
expect(result).toEqual(cleanResult)
})
it('keeps supported clean checks on the preferred relay RPC', async () => {
let resolveProbe!: (result: { clean: boolean }) => void
let resolveRemaining!: (result: { clean: boolean }) => void
const probe = new Promise<{ clean: boolean }>((resolve) => {
resolveProbe = resolve
})
const remaining = new Promise<{ clean: boolean }>((resolve) => {
resolveRemaining = resolve
})
let preferredRequestCount = 0
mux.request.mockImplementation((method) => {
expect(method).toBe('git.worktreeIsClean')
preferredRequestCount += 1
return preferredRequestCount === 1 ? probe : remaining
})
const checks = Array.from({ length: 10 }, (_, index) =>
provider.worktreeIsClean(`/repo/worktree-${index}`)
)
await waitForRequestCount(mux.request, 1)
expect(mux.request).toHaveBeenCalledOnce()
resolveProbe({ clean: true })
await waitForRequestCount(mux.request, 10)
expect(mux.request).toHaveBeenCalledTimes(10)
resolveRemaining({ clean: true })
await expect(Promise.all(checks)).resolves.toEqual(
Array.from({ length: 10 }, () => ({ clean: true }))
)
expect(
mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean')
).toHaveLength(10)
expect(mux.request.mock.calls.some(([method]) => method === 'git.status')).toBe(false)
})
it('worktreeIsClean can ignore untracked files', async () => {
const cleanResult = { clean: true }
mux.request.mockResolvedValue(cleanResult)
@@ -113,10 +157,7 @@ describe('SshGitProvider', () => {
})
it('worktreeIsClean falls back to git.status for old relays', async () => {
const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32601
})
mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({
mux.request.mockRejectedValueOnce(methodNotFound('git.worktreeIsClean')).mockResolvedValueOnce({
entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown'
})
@@ -140,26 +181,180 @@ describe('SshGitProvider', () => {
}
})
it('worktreeIsClean filters untracked entries in old-relay fallback', async () => {
const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32601
it('probes an old relay once across sequential clean checks', async () => {
mux.request.mockImplementation(async (method) => {
if (method === 'git.worktreeIsClean') {
throw methodNotFound(method)
}
return CLEAN_STATUS
})
mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({
entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown'
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const worktreePaths = Array.from({ length: 10 }, (_, index) => `/repo/worktree-${index}`)
try {
for (const worktreePath of worktreePaths) {
await expect(provider.worktreeIsClean(worktreePath)).resolves.toEqual({ clean: true })
}
expect(
mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean')
).toHaveLength(1)
expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10)
expect(mux.request).toHaveBeenCalledTimes(11)
expect(warnSpy).toHaveBeenCalledOnce()
} finally {
warnSpy.mockRestore()
}
})
it('shares one old-relay probe across concurrent clean checks', async () => {
let rejectProbe!: (error: Error) => void
const probe = new Promise((_resolve, reject) => {
rejectProbe = reject
})
mux.request.mockImplementation((method) => {
if (method === 'git.worktreeIsClean') {
return probe
}
return Promise.resolve(CLEAN_STATUS)
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const checks = Array.from({ length: 10 }, (_, index) =>
provider.worktreeIsClean(`/repo/worktree-${index}`)
)
try {
await waitForRequestCount(mux.request, 1)
rejectProbe(methodNotFound('git.worktreeIsClean'))
await expect(Promise.all(checks)).resolves.toEqual(
Array.from({ length: 10 }, () => ({ clean: true }))
)
expect(
mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean')
).toHaveLength(1)
expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10)
expect(mux.request).toHaveBeenCalledTimes(11)
expect(warnSpy).toHaveBeenCalledOnce()
} finally {
warnSpy.mockRestore()
}
})
it('retries the preferred clean RPC after a transport failure', async () => {
const transportError = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32602
})
mux.request.mockRejectedValueOnce(transportError).mockResolvedValueOnce({ clean: true })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(provider.worktreeIsClean('/repo/first')).rejects.toBe(transportError)
await expect(provider.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true })
expect(mux.request.mock.calls.map(([method]) => method)).toEqual([
'git.worktreeIsClean',
'git.worktreeIsClean'
])
expect(warnSpy).not.toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('lets concurrent waiters retry after a transient probe failure', async () => {
const transportError = new Error('connection closed')
let rejectProbe!: (error: Error) => void
let resolveRetries!: (result: { clean: boolean }) => void
const probe = new Promise((_resolve, reject) => {
rejectProbe = reject
})
const retries = new Promise<{ clean: boolean }>((resolve) => {
resolveRetries = resolve
})
let preferredRequestCount = 0
mux.request.mockImplementation((method) => {
expect(method).toBe('git.worktreeIsClean')
preferredRequestCount += 1
return preferredRequestCount === 1 ? probe : retries
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const checks = Array.from({ length: 3 }, (_, index) =>
provider.worktreeIsClean(`/repo/worktree-${index}`)
)
const settledChecks = Promise.allSettled(checks)
try {
await waitForRequestCount(mux.request, 1)
rejectProbe(transportError)
await waitForRequestCount(mux.request, 3)
resolveRetries({ clean: true })
const results = await settledChecks
expect(results[0]).toEqual({ status: 'rejected', reason: transportError })
expect(results.slice(1)).toEqual([
{ status: 'fulfilled', value: { clean: true } },
{ status: 'fulfilled', value: { clean: true } }
])
await expect(provider.worktreeIsClean('/repo/later')).resolves.toEqual({ clean: true })
expect(mux.request).toHaveBeenCalledTimes(4)
expect(warnSpy).not.toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('re-probes clean support after the provider is replaced', async () => {
mux.request.mockImplementation(async (method) => {
if (method === 'git.worktreeIsClean') {
throw methodNotFound(method)
}
return CLEAN_STATUS
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(provider.worktreeIsClean('/repo/first')).resolves.toEqual({ clean: true })
const replacement = new SshGitProvider('conn-1', mux as never)
await expect(replacement.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true })
expect(
mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean')
).toHaveLength(2)
expect(warnSpy).toHaveBeenCalledTimes(2)
} finally {
warnSpy.mockRestore()
}
})
it('worktreeIsClean filters untracked entries in old-relay fallback', async () => {
mux.request.mockImplementation(async (method) => {
if (method === 'git.worktreeIsClean') {
throw methodNotFound(method)
}
return {
entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown'
}
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(provider.worktreeIsClean('/home/user/feat')).resolves.toEqual({
clean: false,
stdout: 'untracked untracked: scratch.txt'
})
await expect(
provider.worktreeIsClean('/home/user/feat', { includeUntracked: false })
).resolves.toEqual({ clean: true })
expect(mux.request).toHaveBeenNthCalledWith(
2,
3,
'git.status',
{ worktreePath: '/home/user/feat' },
{ signal: expect.any(AbortSignal) }
)
expect(
mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean')
).toHaveLength(1)
} finally {
warnSpy.mockRestore()
}
@@ -183,11 +378,7 @@ describe('SshGitProvider', () => {
})
it('forceDeletePreservedBranch maps old relays to the reconnect message', async () => {
const methodNotFound = Object.assign(
new Error('Method not found: git.forceDeletePreservedBranch'),
{ code: -32601 }
)
mux.request.mockRejectedValueOnce(methodNotFound)
mux.request.mockRejectedValueOnce(methodNotFound('git.forceDeletePreservedBranch'))
await expect(
provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123')
+39 -30
View File
@@ -1,9 +1,12 @@
import type { GitStatusResult } from '../../shared/git-status-types'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { CapabilityProbeCache } from '../../shared/capability-probe-cache'
import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors'
import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider'
const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const
function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined {
if (entries.length === 0) {
return undefined
@@ -20,6 +23,10 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un
export class SshGitWorktreeProvider extends SshGitReviewHeadProvider {
private loggedWorktreeIsCleanFallback = false
// Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed.
private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache<
typeof WORKTREE_IS_CLEAN_CAPABILITY
>(Number.POSITIVE_INFINITY)
async listWorktrees(
repoPath: string,
@@ -67,37 +74,39 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider {
worktreePath: string,
options: { includeUntracked?: boolean } = {}
): Promise<{ clean: boolean; stdout?: string }> {
try {
const result = (await this.mux.request('git.worktreeIsClean', {
worktreePath,
...(options.includeUntracked === false ? { includeUntracked: false } : {})
})) as { clean: boolean; stdout?: string }
if (options.includeUntracked === false) {
if (!result.clean && result.stdout === undefined) {
return result
return this.worktreeIsCleanCapabilityCache.runWithFallback(
WORKTREE_IS_CLEAN_CAPABILITY,
async () => {
const result = (await this.mux.request('git.worktreeIsClean', {
worktreePath,
...(options.includeUntracked === false ? { includeUntracked: false } : {})
})) as { clean: boolean; stdout?: string }
if (options.includeUntracked === false) {
if (!result.clean && result.stdout === undefined) {
return result
}
const trackedStdout = filterUntrackedPorcelainStatus(result.stdout)
return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) }
}
const trackedStdout = filterUntrackedPorcelainStatus(result.stdout)
return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) }
}
return result
} catch (error) {
if (!isJsonRpcMethodNotFoundError(error)) {
throw error
}
if (!this.loggedWorktreeIsCleanFallback) {
this.loggedWorktreeIsCleanFallback = true
console.warn(
'[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check'
)
}
const status = await this.getStatus(worktreePath)
const entries =
options.includeUntracked === false
? status.entries.filter((entry) => entry.area !== 'untracked')
: status.entries
const clean = entries.length === 0
return { clean, stdout: formatStatusEntriesForCleanCheck(entries) }
}
return result
},
async () => {
if (!this.loggedWorktreeIsCleanFallback) {
this.loggedWorktreeIsCleanFallback = true
console.warn(
'[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check'
)
}
const status = await this.getStatus(worktreePath)
const entries =
options.includeUntracked === false
? status.entries.filter((entry) => entry.area !== 'untracked')
: status.entries
const clean = entries.length === 0
return { clean, stdout: formatStatusEntriesForCleanCheck(entries) }
},
isJsonRpcMethodNotFoundError
)
}
async refreshLocalBaseRefForWorktreeCreate(args: {
+75 -4
View File
@@ -1,18 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listWorktreesMock } = vi.hoisted(() => ({
listWorktreesMock: vi.fn()
const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
listWorktreesMock: vi.fn(),
listWorktreesStrictMock: vi.fn()
}))
vi.mock('./git/worktree', () => ({
listWorktrees: listWorktreesMock
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesStrictMock
}))
import { createFolderWorktree, isRepoRoot, listRepoWorktrees } from './repo-worktrees'
import {
createFolderWorktree,
isRepoRoot,
listLocalRepoWorktreesStrict,
listRepoWorktrees
} from './repo-worktrees'
describe('repo-worktrees', () => {
beforeEach(() => {
listWorktreesMock.mockReset()
listWorktreesStrictMock.mockReset()
})
it('creates a stable synthetic worktree for folder repos', () => {
@@ -56,6 +64,29 @@ describe('repo-worktrees', () => {
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('returns the synthetic folder worktree for strict local listing', async () => {
const result = await listLocalRepoWorktreesStrict({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
expect(result).toEqual([
createFolderWorktree({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
])
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
it('delegates to git worktree listing for git repos', async () => {
listWorktreesMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
@@ -78,6 +109,46 @@ describe('repo-worktrees', () => {
expect(result).toHaveLength(1)
})
it('delegates strict local listing with the signal and WSL options', async () => {
listWorktreesStrictMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
])
const signal = new AbortController().signal
const result = await listLocalRepoWorktreesStrict(
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
},
{ signal, wslDistro: 'Ubuntu' }
)
expect(listWorktreesStrictMock).toHaveBeenCalledWith('/workspace/repo', {
signal,
wslDistro: 'Ubuntu'
})
expect(result).toHaveLength(1)
})
it('rejects strict listing for remote repos', async () => {
await expect(
listLocalRepoWorktreesStrict({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1',
kind: 'git'
})
).rejects.toThrow('remote repository')
expect(listWorktreesStrictMock).not.toHaveBeenCalled()
})
it('treats Windows repo root casing differences as the same local root', () => {
const repos = [
{
+16 -1
View File
@@ -1,6 +1,6 @@
import type { Repo } from '../shared/repo-types'
import type { GitWorktreeInfo } from '../shared/worktree/types'
import { listWorktrees } from './git/worktree'
import { listWorktrees, listWorktreesStrict } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
@@ -51,3 +51,18 @@ export async function listRepoWorktrees(
? await listWorktrees(repo.path, options)
: await listWorktrees(repo.path)
}
export async function listLocalRepoWorktreesStrict(
repo: Repo,
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
if (repo.connectionId) {
throw new Error('Cannot list worktrees for a remote repository')
}
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktreesStrict(repo.path, options)
: await listWorktreesStrict(repo.path)
}
@@ -121,6 +121,7 @@ describe('startup ordering', () => {
expect(desktopStartup).toContain(
'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })'
)
expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices')
expect(desktopStartup).toContain('shellPathReady,')
expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices')
expect(barrier).toContain('managedWslCliStartupBarrierReady')
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('headless PTY registry hydration ordering', () => {
it('uses exactly one deferred-or-immediate desktop hydration path', () => {
const source = readFileSync(
join(process.cwd(), 'src/main/window/attach-main-window-services.ts'),
'utf8'
)
const start = source.indexOf('const localPtyProviderStartupReady =')
const end = source.indexOf('registerSshHandlers(', start)
const hydration = source.slice(start, end)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
expect(hydration).toContain('if (localPtyProviderStartupReady)')
expect(hydration).toContain('.then(() => hydrateLocalPtyRegistryAtBoot(store))')
expect(hydration).toContain('} else {\n void hydrateLocalPtyRegistryAtBoot(store)')
expect(hydration.match(/hydrateLocalPtyRegistryAtBoot\(store\)/g)).toHaveLength(2)
})
it('hydrates Electron serve after provider and handler readiness but before RPC', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const serve = source.indexOf('if (serveOptions) {')
const provider = source.indexOf('await localPtyProviderStartupReady', serve)
const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider)
const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration)
const readiness = source.indexOf('await printServeReady(serveOptions)', rpc)
expect(serve).toBeGreaterThanOrEqual(0)
expect(provider).toBeGreaterThan(serve)
expect(handlersAndHydration).toBeGreaterThan(provider)
expect(rpc).toBeGreaterThan(handlersAndHydration)
expect(readiness).toBeGreaterThan(rpc)
})
it('hydrates orcad after Store and daemon readiness but before RPC and publication', () => {
const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8')
const store = source.indexOf('const store = new Store(')
const daemon = source.indexOf('await startOrcadDaemon()', store)
const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', daemon)
const rpc = source.indexOf('await rpc.start()', handlersAndHydration)
const readiness = source.indexOf('await new ServeReadinessPublisher().publish(', rpc)
expect(store).toBeGreaterThanOrEqual(0)
expect(daemon).toBeGreaterThan(store)
expect(handlersAndHydration).toBeGreaterThan(daemon)
expect(rpc).toBeGreaterThan(handlersAndHydration)
expect(readiness).toBeGreaterThan(rpc)
})
})
@@ -21,12 +21,19 @@ describe('serve desktop activation wiring', () => {
)
const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex)
const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex)
const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex)
const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex)
const headlessRegistrationIndex = source.indexOf(
'await registerHeadlessPtyRuntime(',
serveIndex
)
const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex)
expect(startupIndex).toBeGreaterThanOrEqual(0)
expect(startupIndex).toBeLessThan(serveIndex)
expect(ptyReadyIndex).toBeGreaterThan(serveIndex)
expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex)
expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex)
expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex)
expect(headlessRegistrationIndex).toBeLessThan(rpcIndex)
expect(source).not.toContain(
'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()'
)
@@ -10,6 +10,29 @@ function deferred(): { promise: Promise<void>; resolve: () => void } {
}
describe('Windows desktop shell PATH startup', () => {
it('binds the services promise before opening the window while shell PATH is unresolved', () => {
const shellPath = deferred()
const events: string[] = []
const bindServices = vi.fn(() => events.push('bind'))
const openWindow = vi.fn(() => events.push('open'))
const options = {
bindServices,
openWindow,
shellPathReady: shellPath.promise,
startServices: vi.fn(() => ({
firstWindowReady: Promise.resolve(),
localPtyReady: Promise.resolve(),
localPtyProviderReady: Promise.resolve()
}))
}
startWindowsDesktopBeforeShellPathReady(options)
expect(events).toEqual(['bind', 'open'])
expect(bindServices).toHaveBeenCalledOnce()
expect(options.startServices).not.toHaveBeenCalled()
})
it('opens the first window while shell PATH hydration remains unresolved', async () => {
const shellPath = deferred()
const window = { visible: true }
@@ -21,6 +44,7 @@ describe('Windows desktop shell PATH startup', () => {
}))
const startup = startWindowsDesktopBeforeShellPathReady({
bindServices: vi.fn(),
openWindow,
shellPathReady: shellPath.promise,
startServices
@@ -5,6 +5,7 @@ export type WindowsDesktopStartupServices = {
}
type WindowsDesktopShellPathStartupOptions<TWindow> = {
bindServices: (services: Promise<WindowsDesktopStartupServices>) => void
openWindow: () => TWindow
shellPathReady: Promise<void>
startServices: () => WindowsDesktopStartupServices
@@ -14,5 +15,6 @@ export function startWindowsDesktopBeforeShellPathReady<TWindow>(
options: WindowsDesktopShellPathStartupOptions<TWindow>
): { window: TWindow; services: Promise<WindowsDesktopStartupServices> } {
const services = options.shellPathReady.then(options.startServices)
options.bindServices(services)
return { window: options.openWindow(), services }
}
@@ -271,8 +271,8 @@ describe('attachMainWindowServices', () => {
expect(mainWindow.webContents.reload).toHaveBeenCalledTimes(1)
})
it('retries local PTY registry hydration after local startup services are ready', async () => {
const localStartup = deferred()
it('hydrates once after the local PTY provider barrier resolves', async () => {
const providerStartup = deferred()
const store = createStore()
attachMainWindowServices(
@@ -281,18 +281,17 @@ describe('attachMainWindowServices', () => {
createRuntime() as never,
undefined,
undefined,
{ awaitLocalPtyStartup: () => localStartup.promise }
{ awaitLocalPtyProviderStartup: () => providerStartup.promise }
)
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(1)
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store)
expect(hydrateLocalPtyRegistryAtBootMock).not.toHaveBeenCalled()
localStartup.resolve()
await localStartup.promise
providerStartup.resolve()
await providerStartup.promise
await Promise.resolve()
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(2)
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenLastCalledWith(store)
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledOnce()
expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store)
})
it('passes injected update quit cleanup to the auto-updater', async () => {
@@ -106,11 +106,9 @@ export function attachMainWindowServices(
scheduleHistoryGc(async () => {
return getKnownWorktreeIdsForHistoryGc(store)
})
// Why: daemon PTYs survive renderer restarts, so at boot they're unregistered; hydrate so they aren't mislabeled REMOTE (idempotent, safe to re-run).
void hydrateLocalPtyRegistryAtBoot(store)
const localPtyStartupReady = options?.awaitLocalPtyStartup?.()
if (localPtyStartupReady) {
void localPtyStartupReady
const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.()
if (localPtyProviderStartupReady) {
void localPtyProviderStartupReady
.then(() => hydrateLocalPtyRegistryAtBoot(store))
.catch((error) => {
console.warn(
@@ -118,6 +116,8 @@ export function attachMainWindowServices(
error instanceof Error ? error.message : String(error)
)
})
} else {
void hydrateLocalPtyRegistryAtBoot(store)
}
registerSshHandlers(store, () => mainWindow, runtime)
registerRemoteWorkspaceHandlers(store, () => mainWindow)
+120 -17
View File
@@ -57,6 +57,82 @@ function createStore(
}
describe('pruneLineageForMissingRepoWorktrees', () => {
it('keeps lineage stored under another spelling of a live worktree path', () => {
const parentId = 'repo-1::/repo'
const childId = 'repo-1::/repo/./child'
const edge = lineage(childId, parentId)
const worktreeLineageById = { [childId]: edge }
const workspaceLineageByChildKey = {
[worktreeWorkspaceKey(childId)]: workspaceLineage(childId, parentId)
}
const metaById = {
[parentId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta
}
const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById)
pruneLineageForMissingRepoWorktrees(
store as never,
repo,
[
{ path: '/repo', head: 'a', branch: 'main', isBare: false, isMainWorktree: true },
{ path: '/repo/child/', head: 'b', branch: 'child', isBare: false, isMainWorktree: false }
],
{ platform: 'linux' }
)
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(store.removeWorkspaceLineage).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(worktreeLineageById[childId]).toBe(edge)
})
it('keeps captured metadata lineage not removed after Git canonicalizes paths', () => {
const parentId = 'repo-1::/alias/parent'
const childId = 'repo-1::/alias/child'
const edge = lineage(childId, parentId)
const worktreeLineageById = { [childId]: edge }
const workspaceLineageByChildKey = {
[worktreeWorkspaceKey(childId)]: workspaceLineage(childId, parentId)
}
const metaById = {
[childId]: { instanceId: edge.worktreeInstanceId } as WorktreeMeta,
[parentId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta
}
const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById)
pruneLineageForMissingRepoWorktrees(
store as never,
repo,
[
{
path: '/canonical/parent',
head: 'a',
branch: 'parent',
isBare: false,
isMainWorktree: false
},
{
path: '/canonical/child',
head: 'b',
branch: 'child',
isBare: false,
isMainWorktree: false
}
],
{
platform: 'linux',
preservedMetadataCandidateIds: new Set([parentId, childId])
}
)
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(store.removeWorkspaceLineage).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(worktreeLineageById[childId]).toBe(edge)
expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBeDefined()
expect(metaById[parentId].instanceId).toBe(edge.parentWorktreeInstanceId)
})
it('refuses an empty scan when the repo still has registered lineage', () => {
const parentId = 'repo-1::/repo/parent'
const childId = 'repo-1::/repo/child'
@@ -78,7 +154,7 @@ describe('pruneLineageForMissingRepoWorktrees', () => {
expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBe(workspaceEdge)
})
it('prunes missing children and rotates missing parents after a trusted non-empty scan', () => {
it('prunes and rotates missing IDs absent from preserved metadata candidates', () => {
const liveParentId = 'repo-1::/repo/live-parent'
const missingChildId = 'repo-1::/repo/missing-child'
const liveChildId = 'repo-1::/repo/live-child'
@@ -99,25 +175,31 @@ describe('pruneLineageForMissingRepoWorktrees', () => {
}
const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById)
pruneLineageForMissingRepoWorktrees(store as never, repo, [
{
path: '/repo/live-parent',
head: 'a',
branch: 'main',
isBare: false,
isMainWorktree: false
},
{
path: '/repo/live-child',
head: 'b',
branch: 'child',
isBare: false,
isMainWorktree: false
}
])
pruneLineageForMissingRepoWorktrees(
store as never,
repo,
[
{
path: '/repo/live-parent',
head: 'a',
branch: 'main',
isBare: false,
isMainWorktree: false
},
{
path: '/repo/live-child',
head: 'b',
branch: 'child',
isBare: false,
isMainWorktree: false
}
],
{ preservedMetadataCandidateIds: new Set([liveParentId, liveChildId]) }
)
expect(store.removeWorktreeLineage).toHaveBeenCalledWith(missingChildId)
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith(liveChildId)
expect(store.removeWorkspaceLineage).toHaveBeenCalledWith(worktreeWorkspaceKey(missingChildId))
expect(store.setWorktreeMeta).toHaveBeenCalledWith(missingParentId, {
instanceId: expect.any(String)
})
@@ -126,4 +208,25 @@ describe('pruneLineageForMissingRepoWorktrees', () => {
missingParentEdge.parentWorktreeInstanceId
)
})
it('does not rematerialize metadata already removed for a missing parent', () => {
const liveChildId = 'repo-1::/repo/live-child'
const removedParentId = 'repo-1::/repo/removed-parent'
const edge = lineage(liveChildId, removedParentId)
const worktreeLineageById = { [liveChildId]: edge }
const store = createStore(worktreeLineageById, {}, {})
pruneLineageForMissingRepoWorktrees(store as never, repo, [
{
path: '/repo/live-child',
head: 'child-head',
branch: 'child',
isBare: false,
isMainWorktree: false
}
])
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(worktreeLineageById[liveChildId]).toBe(edge)
})
})
+30 -5
View File
@@ -4,6 +4,8 @@ import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/linea
import type { GitWorktreeInfo } from '../shared/worktree/types'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
import { splitWorktreeId } from '../shared/worktree/id'
import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-comparison'
import type { Store } from './persistence'
function worktreeIdBelongsToRepo(worktreeId: string, repoPrefix: string): boolean {
@@ -37,8 +39,13 @@ function hasStoredRepoLineage(
export function pruneLineageForMissingRepoWorktrees(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[]
gitWorktrees: GitWorktreeInfo[],
options: {
platform?: NodeJS.Platform
preservedMetadataCandidateIds?: ReadonlySet<string>
} = {}
): void {
const { platform = process.platform, preservedMetadataCandidateIds } = options
if (
typeof store.getAllWorktreeLineage !== 'function' ||
typeof store.removeWorktreeLineage !== 'function'
@@ -56,6 +63,24 @@ export function pruneLineageForMissingRepoWorktrees(
return
}
const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`))
const livePathKeys = new Set(
[repo.path, ...gitWorktrees.map(({ path }) => path)].map((pathValue) =>
worktreeRetentionPathComparisonKey(pathValue, platform)
)
)
const isLive = (worktreeId: string): boolean => {
if (liveIds.has(worktreeId) || preservedMetadataCandidateIds?.has(worktreeId)) {
return true
}
const worktreePath = splitWorktreeId(worktreeId)?.worktreePath
if (!worktreePath) {
return false
}
if (livePathKeys.has(worktreeRetentionPathComparisonKey(worktreePath, platform))) {
return true
}
return false
}
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id)
const canMutateWorktree = (worktreeId: string): boolean => {
@@ -68,7 +93,7 @@ export function pruneLineageForMissingRepoWorktrees(
childScope?.type === 'worktree' &&
worktreeIdBelongsToRepo(childScope.worktreeId, repoPrefix) &&
canMutateWorktree(childScope.worktreeId) &&
!liveIds.has(childScope.worktreeId) &&
!isLive(childScope.worktreeId) &&
isWorkspaceKey(childWorkspaceKey)
) {
store.removeWorkspaceLineage?.(childWorkspaceKey)
@@ -78,7 +103,7 @@ export function pruneLineageForMissingRepoWorktrees(
if (
worktreeIdBelongsToRepo(childId, repoPrefix) &&
canMutateWorktree(childId) &&
!liveIds.has(childId)
!isLive(childId)
) {
// Why: a proven-missing path must not transfer its lineage to a future checkout at that path.
store.removeWorktreeLineage(childId)
@@ -87,10 +112,10 @@ export function pruneLineageForMissingRepoWorktrees(
if (
worktreeIdBelongsToRepo(lineage.parentWorktreeId, repoPrefix) &&
canMutateWorktree(lineage.parentWorktreeId) &&
!liveIds.has(lineage.parentWorktreeId)
!isLive(lineage.parentWorktreeId)
) {
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
if (parentMeta?.instanceId === lineage.parentWorktreeInstanceId) {
// Why: rotate a proven-missing parent's identity once so path reuse cannot validate old lineage.
store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() })
}
@@ -0,0 +1,24 @@
import { splitWorktreeId } from '../shared/worktree/id'
import { worktreePathComparisonKey } from './ipc/worktree-path-comparison'
/** Comparison keys used only to retain state; canonical equivalence must never select a delete target. */
export function worktreeRetentionPathComparisonKey(
pathValue: string,
platform: NodeJS.Platform
): string {
return worktreePathComparisonKey(pathValue.normalize('NFC'), platform)
}
export function worktreeRetentionIdComparisonKey(
worktreeId: string,
platform: NodeJS.Platform
): string | null {
const parsed = splitWorktreeId(worktreeId)
if (!parsed?.repoId || !parsed.worktreePath) {
return null
}
return JSON.stringify([
parsed.repoId,
worktreeRetentionPathComparisonKey(parsed.worktreePath, platform)
])
}
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DiffCommentCard } from './DiffCommentCard'
@@ -44,6 +44,7 @@ describe('DiffCommentCard content resize', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
@@ -101,4 +102,52 @@ describe('DiffCommentCard content resize', () => {
expect(constructObserver).not.toHaveBeenCalled()
expect(onContentResize).not.toHaveBeenCalled()
})
it('requests rail reflow only when the editing textarea height changes', () => {
const onContentResize = vi.fn()
let scrollHeight = 60
vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockImplementation(
() => scrollHeight
)
const view = render(
<DiffCommentCard
lineNumber={26}
body="A saved note"
onContentResize={onContentResize}
onSubmitEdit={async () => true}
/>
)
fireEvent.click(view.getByRole('button', { name: 'Edit note' }))
expect(onContentResize).toHaveBeenCalledOnce()
const textarea = view.getByRole('textbox')
expect(textarea.style.height).toBe('60px')
fireEvent.change(textarea, { target: { value: 'A saved note!' } })
expect(onContentResize).toHaveBeenCalledOnce()
for (let index = 0; index < 50; index++) {
fireEvent.change(textarea, { target: { value: `A saved note! ${index}` } })
}
expect(textarea.style.height).toBe('60px')
expect(onContentResize).toHaveBeenCalledOnce()
scrollHeight = 400
fireEvent.change(textarea, { target: { value: 'A much taller saved note' } })
expect(textarea.style.height).toBe('240px')
expect(onContentResize).toHaveBeenCalledTimes(2)
scrollHeight = 480
fireEvent.change(textarea, { target: { value: 'A still taller saved note' } })
expect(textarea.style.height).toBe('240px')
expect(onContentResize).toHaveBeenCalledTimes(2)
scrollHeight = 80
fireEvent.change(textarea, { target: { value: 'A shorter saved note' } })
expect(textarea.style.height).toBe('80px')
expect(onContentResize).toHaveBeenCalledTimes(3)
fireEvent.click(view.getByRole('button', { name: 'Cancel' }))
expect(onContentResize).toHaveBeenCalledTimes(4)
})
})
@@ -35,6 +35,13 @@ type Props = {
headerActions?: ReactNode
}
function resizeDiffCommentTextarea(textarea: HTMLTextAreaElement): boolean {
const previousHeight = textarea.style.height
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 240)}px`
return textarea.style.height !== previousHeight
}
export function DiffCommentCard({
lineNumber,
startLine,
@@ -116,8 +123,7 @@ export function DiffCommentCard({
if (!el) {
return
}
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
resizeDiffCommentTextarea(el)
el.focus()
el.setSelectionRange(el.value.length, el.value.length)
onContentResizeRef.current?.()
@@ -281,10 +287,10 @@ export function DiffCommentCard({
value={draft}
onChange={(e) => {
setDraft(e.target.value)
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
onContentResizeRef.current?.()
// Why: rich-review layout measures every note; skip it when this card stayed put.
if (resizeDiffCommentTextarea(e.currentTarget)) {
onContentResizeRef.current?.()
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -0,0 +1,297 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { FileContent } from './editor-panel-content-types'
const classifiers = vi.hoisted(() => ({
getUnsupportedMessage: vi.fn<(content: string) => string | null>(),
exceedsSizeLimit: vi.fn<(content: string) => boolean>()
}))
vi.mock('./markdown-rich-mode', () => ({
getMarkdownRichModeEligibility: ({
content,
sizeOverridden
}: {
content: string
sizeOverridden: boolean
}) => ({
exceedsSizeLimit: !sizeOverridden && classifiers.exceedsSizeLimit(content),
unsupportedMessage: classifiers.getUnsupportedMessage(content)
})
}))
vi.mock('./editor-lazy-views', () => {
const view = (name: string) => () => <div data-editor-view={name} />
return {
MonacoEditor: view('source'),
DiffViewer: view('diff'),
CombinedDiffViewer: view('combined-diff'),
RichMarkdownEditor: view('rich-editor'),
MarkdownPreview: view('preview'),
ImageViewer: view('image'),
ImageDiffViewer: view('image-diff'),
MermaidViewer: view('mermaid'),
CsvViewer: view('csv'),
IpynbViewer: view('notebook')
}
})
vi.mock('./RichMarkdownErrorBoundary', () => ({
RichMarkdownErrorBoundary: ({ children }: { children: React.ReactNode }) => children
}))
vi.mock('./useMarkdownDocuments', () => ({
useMarkdownDocuments: () => ({
markdownDocuments: [],
onOpenDocLink: () => {},
previewProps: { markdownDocuments: [], onOpenDocument: async () => {} },
mdSave: async () => true
})
}))
vi.mock('./useEditorConflictNavigation', () => ({
useEditorConflictNavigation: () => () => undefined
}))
vi.mock('@/store', () => {
const state = {
markdownRichModeSizeOverride: {},
setMarkdownRichModeSizeOverride: () => {},
reloadOpenCheckRunDetailsTab: () => {}
}
return {
useAppStore: Object.assign(
(selector: (storeState: typeof state) => unknown) => selector(state),
{ getState: () => state }
)
}
})
import { EditorContent } from './EditorContent'
import { getEditorPanelRenderModel } from './editor-panel-render-model'
function openFile(
language: 'markdown' | 'typescript' = 'markdown',
mode: 'edit' | 'markdown-preview' = 'edit'
): OpenFile {
const extension = language === 'markdown' ? 'md' : 'ts'
return {
id: `/repo/notes.${extension}`,
filePath: `/repo/notes.${extension}`,
relativePath: `notes.${extension}`,
worktreeId: 'wt-1',
language,
mode,
isDirty: false
}
}
function renderEditPath({
content,
language = 'markdown',
viewMode = 'rich',
mode = 'edit'
}: {
content: string
language?: 'markdown' | 'typescript'
viewMode?: 'source' | 'rich' | 'preview'
mode?: 'edit' | 'markdown-preview'
}) {
const activeFile = openFile(language, mode)
const fileContents = {
[activeFile.id]: { content: '# Saved', isBinary: false }
}
const editorDrafts = { [activeFile.id]: content }
const model = getEditorPanelRenderModel({
activeFile,
fileContents,
editorDrafts,
gitStatusEntries: undefined,
gitBranchEntries: undefined,
markdownViewMode: { [activeFile.id]: viewMode },
markdownRichModeSizeOverride: {},
isChangesMode: false,
canOpenWorkspaceFileBrowser: true
})
const view = render(
<EditorContent
activeFile={activeFile}
viewStateScopeId={activeFile.id}
fileContents={fileContents}
diffContents={{}}
editBuffers={editorDrafts}
openFiles={[activeFile]}
worktreeEntries={[]}
resolvedLanguage={model.resolvedLanguage}
isMarkdown={model.isMarkdown}
isMermaid={model.isMermaid}
isCsv={model.isCsv}
isNotebook={model.isNotebook}
mdViewMode={model.mdViewMode}
inlineMarkdownRenderState={model.inlineMarkdownRenderState}
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
handleContentChange={vi.fn()}
handleContentChangeForFile={vi.fn()}
handleDirtyStateHint={vi.fn()}
handleSave={vi.fn()}
handleSaveForFile={vi.fn()}
reloadContent={vi.fn()}
/>
)
return { model, view }
}
function getGuardedRenderModel({
activeFileOverrides,
fileContent = { content: '# Saved', isBinary: false },
includeFileContent = true,
isChangesMode = false
}: {
activeFileOverrides?: Partial<OpenFile>
fileContent?: FileContent
includeFileContent?: boolean
isChangesMode?: boolean
}) {
const activeFile = { ...openFile(), ...activeFileOverrides }
return getEditorPanelRenderModel({
activeFile,
fileContents: includeFileContent ? { [activeFile.id]: fileContent } : {},
editorDrafts: { [activeFile.id]: '# Draft' },
gitStatusEntries: undefined,
gitBranchEntries: undefined,
markdownViewMode: { [activeFile.id]: 'rich' },
markdownRichModeSizeOverride: {},
isChangesMode,
canOpenWorkspaceFileBrowser: true
})
}
beforeEach(() => {
classifiers.getUnsupportedMessage.mockImplementation((content) =>
content.includes('[reference]:') ? 'Reference links require source mode.' : null
)
classifiers.exceedsSizeLimit.mockImplementation((content) => content.includes('oversized'))
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('inline Markdown render classification', () => {
it('classifies each rich draft once, including after content changes', () => {
const first = renderEditPath({ content: '# First draft' })
expect(first.view.container.innerHTML).toContain('data-editor-view="rich-editor"')
expect(classifiers.getUnsupportedMessage).toHaveBeenCalledTimes(1)
expect(classifiers.getUnsupportedMessage).toHaveBeenLastCalledWith('# First draft')
expect(classifiers.exceedsSizeLimit).toHaveBeenCalledTimes(1)
expect(classifiers.exceedsSizeLimit).toHaveBeenLastCalledWith('# First draft')
first.view.unmount()
const second = renderEditPath({ content: '# Changed draft' })
expect(second.view.container.innerHTML).toContain('data-editor-view="rich-editor"')
expect(classifiers.getUnsupportedMessage).toHaveBeenCalledTimes(2)
expect(classifiers.getUnsupportedMessage).toHaveBeenLastCalledWith('# Changed draft')
expect(classifiers.exceedsSizeLimit).toHaveBeenCalledTimes(2)
expect(classifiers.exceedsSizeLimit).toHaveBeenLastCalledWith('# Changed draft')
})
it.each([
{
name: 'source Markdown',
args: { content: '# Source', viewMode: 'source' as const },
expectedView: 'source',
canExport: false
},
{
name: 'Markdown preview',
args: { content: '# Preview', mode: 'markdown-preview' as const },
expectedView: 'preview',
canExport: true
},
{
name: 'TypeScript',
args: { content: 'const value = 1', language: 'typescript' as const },
expectedView: 'source',
canExport: false
}
])('skips rich eligibility scans for $name', ({ args, expectedView, canExport }) => {
const result = renderEditPath(args)
expect(result.view.container.innerHTML).toContain(`data-editor-view="${expectedView}"`)
expect(result.model.canExportMarkdownToPdf).toBe(canExport)
expect(classifiers.getUnsupportedMessage).not.toHaveBeenCalled()
expect(classifiers.exceedsSizeLimit).not.toHaveBeenCalled()
})
it.each([
{ name: 'Changes mode', args: { isChangesMode: true } },
{ name: 'content that is still loading', args: { includeFileContent: false } },
{
name: 'binary content',
args: { fileContent: { content: '', isBinary: true } }
},
{
name: 'content with a load error',
args: { fileContent: { content: '', isBinary: false, loadError: 'Read failed' } }
},
{
name: 'read-only Markdown',
args: { activeFileOverrides: { readOnly: true } }
},
{
name: 'an unresolved conflict',
args: {
activeFileOverrides: {
conflict: {
kind: 'conflict-editable' as const,
conflictKind: 'both_modified' as const,
conflictStatus: 'unresolved' as const,
conflictStatusSource: 'git' as const
}
}
}
},
{
name: 'a conflict placeholder',
args: {
activeFileOverrides: {
conflict: {
kind: 'conflict-placeholder' as const,
conflictKind: 'both_deleted' as const,
conflictStatus: 'unresolved' as const,
conflictStatusSource: 'git' as const
}
}
}
}
])('skips rich eligibility scans for $name', ({ args }) => {
const model = getGuardedRenderModel(args)
expect(model.inlineMarkdownRenderState).toBeNull()
expect(classifiers.getUnsupportedMessage).not.toHaveBeenCalled()
expect(classifiers.exceedsSizeLimit).not.toHaveBeenCalled()
})
it('preserves unsupported and oversized rich-mode fallbacks', () => {
const unsupported = renderEditPath({ content: '[reference]: https://example.com' })
expect(unsupported.model.canExportMarkdownToPdf).toBe(false)
expect(unsupported.view.getByText('Reference links require source mode.')).toBeTruthy()
expect(unsupported.view.queryByText('Open anyway')).toBeNull()
unsupported.view.unmount()
const oversized = renderEditPath({ content: '# oversized' })
expect(oversized.model.canExportMarkdownToPdf).toBe(false)
expect(oversized.view.getByText(/File is larger than the .* rich editing limit/)).toBeTruthy()
expect(oversized.view.getByText('Open anyway')).toBeTruthy()
})
})
@@ -184,6 +184,7 @@ function props(activeFile: OpenFile, content: string) {
isCsv: false,
isNotebook: false,
mdViewMode: 'rich' as const,
inlineMarkdownRenderState: null,
isChangesMode: false,
sideBySide: false,
pendingEditorReveal: null,
@@ -326,6 +327,10 @@ describe('EditorContent Monaco lifecycle boundary', () => {
resolvedLanguage="markdown"
isMarkdown
mdViewMode="rich"
inlineMarkdownRenderState={{
renderMode: 'rich-editor',
richModeUnsupportedMessage: null
}}
/>
)
@@ -67,6 +67,7 @@ describe('EditorContent', () => {
isCsv={false}
isNotebook
mdViewMode="rich"
inlineMarkdownRenderState={null}
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
@@ -108,6 +109,7 @@ describe('EditorContent', () => {
isCsv={false}
isNotebook={false}
mdViewMode="rich"
inlineMarkdownRenderState={null}
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
@@ -153,6 +155,7 @@ describe('EditorContent', () => {
isCsv={false}
isNotebook={false}
mdViewMode="rich"
inlineMarkdownRenderState={null}
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
@@ -200,6 +203,7 @@ describe('EditorContent', () => {
isCsv={false}
isNotebook={false}
mdViewMode="preview"
inlineMarkdownRenderState={null}
isChangesMode={false}
sideBySide={false}
pendingEditorReveal={null}
@@ -12,6 +12,7 @@ import type { FileContent } from './editor-panel-content-types'
import { translate } from '@/i18n/i18n'
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
import { useMarkdownDocuments } from './useMarkdownDocuments'
import type { MarkdownRenderState } from './markdown-render-mode'
const noopCloseMarkdownTableOfContents = (): void => {}
@@ -47,6 +48,7 @@ export function EditorContent({
isCsv,
isNotebook,
mdViewMode,
inlineMarkdownRenderState,
isChangesMode,
sideBySide,
showMarkdownTableOfContents = false,
@@ -74,6 +76,7 @@ export function EditorContent({
isCsv: boolean
isNotebook: boolean
mdViewMode: MarkdownViewMode
inlineMarkdownRenderState: MarkdownRenderState | null
isChangesMode: boolean
sideBySide: boolean
showMarkdownTableOfContents?: boolean
@@ -238,6 +241,7 @@ export function EditorContent({
isCsv={isCsv}
isNotebook={isNotebook}
mdViewMode={mdViewMode}
inlineMarkdownRenderState={inlineMarkdownRenderState}
isChangesMode={isChangesMode}
sideBySide={sideBySide}
showMarkdownTableOfContents={showMarkdownTableOfContents}
@@ -17,6 +17,7 @@ import type { FileContent } from './editor-panel-content-types'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
import { EditorMarkdownFileSurface } from './EditorMarkdownFileSurface'
import type { MarkdownRenderState } from './markdown-render-mode'
const noopEditorContentChange = (_content: string): void => {}
const noopEditorSave = async (_content: string): Promise<boolean> => false
@@ -39,6 +40,7 @@ export function EditorEditFileSurface({
isCsv,
isNotebook,
mdViewMode,
inlineMarkdownRenderState,
isChangesMode,
sideBySide,
showMarkdownTableOfContents,
@@ -69,6 +71,7 @@ export function EditorEditFileSurface({
isCsv: boolean
isNotebook: boolean
mdViewMode: MarkdownViewMode
inlineMarkdownRenderState: MarkdownRenderState | null
isChangesMode: boolean
sideBySide: boolean
showMarkdownTableOfContents: boolean
@@ -210,6 +213,7 @@ export function EditorEditFileSurface({
editorViewStateKey={editorViewStateKey}
currentContent={currentContent}
mdViewMode={mdViewMode}
inlineMarkdownRenderState={inlineMarkdownRenderState}
showMarkdownTableOfContents={showMarkdownTableOfContents}
showMarkdownFrontmatter={showMarkdownFrontmatter}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
@@ -5,10 +5,8 @@ import { Button } from '@/components/ui/button'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import { formatBytes } from '../status-bar/workspace-space-format'
import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import type { MarkdownRenderState } from './markdown-render-mode'
import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
@@ -20,6 +18,7 @@ export function EditorMarkdownFileSurface({
editorViewStateKey,
currentContent,
mdViewMode,
inlineMarkdownRenderState,
showMarkdownTableOfContents,
showMarkdownFrontmatter,
onCloseMarkdownTableOfContents,
@@ -35,6 +34,7 @@ export function EditorMarkdownFileSurface({
editorViewStateKey: string
currentContent: string
mdViewMode: MarkdownViewMode
inlineMarkdownRenderState: MarkdownRenderState | null
showMarkdownTableOfContents: boolean
showMarkdownFrontmatter: boolean
onCloseMarkdownTableOfContents: () => void
@@ -45,18 +45,15 @@ export function EditorMarkdownFileSurface({
handleDirtyStateHint: (dirty: boolean) => void
monacoEditor: React.JSX.Element
}): React.JSX.Element {
const sizeOverridden = useAppStore((s) => s.markdownRichModeSizeOverride[activeFile.id] === true)
const setSizeOverride = useAppStore((s) => s.setMarkdownRichModeSizeOverride)
const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent)
const renderMode = getMarkdownRenderMode({
exceedsRichModeSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(currentContent),
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
viewMode: mdViewMode
})
if (activeFile.conflict?.conflictStatus === 'unresolved') {
return <div className="h-full min-h-0">{monacoEditor}</div>
}
if (!inlineMarkdownRenderState) {
return <div className="h-full min-h-0">{monacoEditor}</div>
}
const { renderMode, richModeUnsupportedMessage } = inlineMarkdownRenderState
if (renderMode === 'source' && mdViewMode === 'rich') {
// Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override.
const isSizeFallback = richModeUnsupportedMessage === null
@@ -149,6 +149,7 @@ export function EditorPanelShell({
isCsv={model.isCsv}
isNotebook={model.isNotebook}
mdViewMode={model.mdViewMode}
inlineMarkdownRenderState={model.inlineMarkdownRenderState}
isChangesMode={model.isDiffSurface && !model.isSingleDiff}
sideBySide={sideBySide}
pendingEditorReveal={pendingEditorReveal}
@@ -12,9 +12,8 @@ import { getEditorHeaderOpenFileState } from './editor-header'
import type { EditorToggleValue } from './EditorViewToggle'
import type { FileContent } from './editor-panel-content-types'
import { canUseChangesModeForFile } from './editor-panel-file-mode'
import { getMarkdownRenderMode } from './markdown-render-mode'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { getMarkdownRenderMode, type MarkdownRenderState } from './markdown-render-mode'
import { getMarkdownRichModeEligibility } from './markdown-rich-mode'
type StoreState = ReturnType<typeof useAppStore.getState>
@@ -129,17 +128,36 @@ export function getEditorPanelRenderModel({
const shouldShowMarkdownExportAction =
viewerLanguage === 'markdown' &&
(activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview')
const inlineMarkdownRenderMode =
activeFile.mode === 'edit' && inlineMarkdownContent !== null
? getMarkdownRenderMode({
exceedsRichModeSizeLimit:
markdownRichModeSizeOverride[activeFile.id] !== true &&
exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent),
hasRichModeUnsupportedContent:
getMarkdownRichModeUnsupportedMessage(inlineMarkdownContent) !== null,
viewMode: mdViewMode
const inlineFileContent = activeFile.mode === 'edit' ? fileContents[activeFile.id] : undefined
const canRenderInlineMarkdown =
viewerLanguage === 'markdown' &&
activeFile.mode === 'edit' &&
inlineMarkdownContent !== null &&
!isChangesMode &&
inlineFileContent !== undefined &&
inlineFileContent.isBinary !== true &&
!inlineFileContent.loadError &&
activeFile.conflict?.kind !== 'conflict-placeholder' &&
activeFile.conflict?.conflictStatus !== 'unresolved'
let inlineMarkdownRenderState: MarkdownRenderState | null = null
if (canRenderInlineMarkdown) {
const shouldClassifyRichMode = mdViewMode === 'rich'
const richModeEligibility = shouldClassifyRichMode
? getMarkdownRichModeEligibility({
content: inlineMarkdownContent,
sizeOverridden: markdownRichModeSizeOverride[activeFile.id] === true
})
: null
const richModeUnsupportedMessage = richModeEligibility?.unsupportedMessage ?? null
inlineMarkdownRenderState = {
renderMode: getMarkdownRenderMode({
exceedsRichModeSizeLimit: richModeEligibility?.exceedsSizeLimit ?? false,
hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null,
viewMode: mdViewMode
}),
richModeUnsupportedMessage
}
}
const canExportMarkdownToPdf =
shouldShowMarkdownExportAction &&
((activeFile.mode === 'markdown-preview' &&
@@ -149,8 +167,8 @@ export function getEditorPanelRenderModel({
(activeFile.mode === 'edit' &&
fileContents[activeFile.id] !== undefined &&
!isChangesMode &&
inlineMarkdownRenderMode !== null &&
inlineMarkdownRenderMode !== 'source' &&
inlineMarkdownRenderState !== null &&
inlineMarkdownRenderState.renderMode !== 'source' &&
fileContents[activeFile.id]?.isBinary !== true &&
!fileContents[activeFile.id]?.loadError &&
activeFile.conflict?.conflictStatus !== 'unresolved'))
@@ -180,6 +198,7 @@ export function getEditorPanelRenderModel({
isMarkdownTableOfContentsDisabled: hasViewModeToggle && mdViewMode === 'source',
shouldShowMarkdownExportAction,
canExportMarkdownToPdf,
inlineMarkdownRenderState,
canShowMarkdownTableOfContents:
viewerLanguage === 'markdown' &&
(hasViewModeToggle || activeFile.mode === 'markdown-preview'),
@@ -2,6 +2,11 @@ import type { MarkdownViewMode } from '@/store/slices/editor'
export type MarkdownRenderMode = 'source' | 'rich-editor' | 'preview'
export type MarkdownRenderState = {
renderMode: MarkdownRenderMode
richModeUnsupportedMessage: string | null
}
export function getMarkdownRenderMode({
exceedsRichModeSizeLimit,
hasRichModeUnsupportedContent,
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import {
getMarkdownRichModeEligibility,
getMarkdownRichModeUnsupportedMessage
} from './markdown-rich-mode'
afterEach(() => {
vi.restoreAllMocks()
@@ -46,6 +50,31 @@ describe('getMarkdownRichModeUnsupportedMessage', () => {
).toBeNull()
})
it('allows bare lowercase placeholders in prose', () => {
expect(
getMarkdownRichModeUnsupportedMessage(
'Use --host runtime:<id>, --output <path>, and --project <project-id>.\n'
)
).toBeNull()
})
it('allows bare lowercase placeholders in large documents', () => {
const content = `${'a'.repeat(50_001)}\nUse --host runtime:<id> and --project <project-id>.\n`
expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull()
})
it('still blocks large documents with actual html or jsx', () => {
const prefix = 'a'.repeat(50_001)
expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\n<span>text</span>\n`)).not.toBeNull()
expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\n<id>text</id>\n`)).not.toBeNull()
expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\n<Widget />\n`)).not.toBeNull()
expect(
getMarkdownRichModeUnsupportedMessage(`${prefix}\n<project-id value="1">\n`)
).not.toBeNull()
})
it('allows block html and mdx-like tags by preserving them as passthrough nodes', () => {
expect(getMarkdownRichModeUnsupportedMessage('<Widget />\n')).toBeNull()
expect(getMarkdownRichModeUnsupportedMessage('<div>block</div>\n')).toBeNull()
@@ -90,4 +119,17 @@ describe('getMarkdownRichModeUnsupportedMessage', () => {
)
expect(usedGlobalHtmlFragmentMatch).toBe(false)
})
it('keeps unsupported content blocked when it also exceeds the size limit', () => {
const content = `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)}<Widget />`
expect(getMarkdownRichModeEligibility({ content, sizeOverridden: false })).toEqual({
exceedsSizeLimit: true,
unsupportedMessage: expect.any(String)
})
expect(getMarkdownRichModeEligibility({ content, sizeOverridden: true })).toEqual({
exceedsSizeLimit: false,
unsupportedMessage: expect.any(String)
})
})
})
@@ -1,5 +1,7 @@
import { defaultSchema } from 'rehype-sanitize'
import { getRichMarkdownRoundTripOutput } from './markdown-round-trip'
import { extractFrontMatter } from './markdown-frontmatter'
import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit'
import { translate } from '@/i18n/i18n'
export type MarkdownRichModeUnsupportedReason =
@@ -14,6 +16,13 @@ type UnsupportedMatch = {
pattern: RegExp
}
export type MarkdownRichModeEligibility = {
exceedsSizeLimit: boolean
unsupportedMessage: string | null
}
const KNOWN_MARKDOWN_HTML_TAG_NAMES = new Set(defaultSchema.tagNames ?? [])
const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [
{
reason: 'html-or-jsx',
@@ -66,7 +75,7 @@ export function getMarkdownRichModeUnsupportedMessage(content: string): string |
// opinion when HTML is detected, to verify the HTML survives the round-trip
// before blocking the user from rich mode.
const htmlMatcher = UNSUPPORTED_PATTERNS.find((m) => m.reason === 'html-or-jsx')
const hasHtml = htmlMatcher && htmlMatcher.pattern.test(contentWithoutCode)
const hasHtml = htmlMatcher && hasHtmlOrJsx(contentWithoutCode, htmlMatcher.pattern)
for (const matcher of UNSUPPORTED_PATTERNS) {
if (matcher.reason === 'html-or-jsx') {
@@ -91,6 +100,44 @@ export function getMarkdownRichModeUnsupportedMessage(content: string): string |
return null
}
export function getMarkdownRichModeEligibility({
content,
sizeOverridden
}: {
content: string
sizeOverridden: boolean
}): MarkdownRichModeEligibility {
const exceedsSizeLimit = !sizeOverridden && exceedsMarkdownRichModeSizeLimit(content)
return {
exceedsSizeLimit,
unsupportedMessage: getMarkdownRichModeUnsupportedMessage(content)
}
}
function hasHtmlOrJsx(content: string, pattern: RegExp): boolean {
for (const match of content.matchAll(new RegExp(pattern, 'g'))) {
if (isHtmlOrJsxFragment(match[0])) {
return true
}
}
return false
}
function isHtmlOrJsxFragment(fragment: string): boolean {
if (fragment.startsWith('<!--') || fragment.startsWith('</')) {
return true
}
const tagMatch = fragment.match(/^<([A-Za-z][\w.:-]*)/)
const tagName = tagMatch?.[1]
if (!tagName) {
return false
}
const suffix = fragment.slice(tagName.length + 1, -1)
return suffix.length > 0 || KNOWN_MARKDOWN_HTML_TAG_NAMES.has(tagName.toLowerCase())
}
function stripMarkdownCode(content: string): string {
let sanitized = ''
let activeFence: '`' | '~' | null = null
@@ -3,6 +3,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
hasUnreadAgentCompletionForTerminalTab,
resetUnreadAgentCompletionTabIdsCacheForTest,
resetTerminalTabActivityFlagsCacheForTest,
resolveTerminalTabActivityStatus,
resolveTerminalTabAttentionBadge,
@@ -41,6 +42,7 @@ const LIVE_PTY = { [TAB_ID]: ['pty-1'] }
beforeEach(() => {
resetTerminalTabActivityFlagsCacheForTest()
resetUnreadAgentCompletionTabIdsCacheForTest()
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
@@ -48,6 +50,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers()
resetTerminalTabActivityFlagsCacheForTest()
resetUnreadAgentCompletionTabIdsCacheForTest()
})
describe('resolveTerminalTabActivityStatus', () => {
@@ -267,6 +270,7 @@ describe('hasUnreadAgentCompletionForTerminalTab', () => {
expect(
hasUnreadAgentCompletionForTerminalTab({ [`tab-2:${SECOND_LEAF_ID}`]: true }, TAB_ID)
).toBe(false)
expect(hasUnreadAgentCompletionForTerminalTab(undefined, TAB_ID)).toBe(false)
})
// Why: the param accepts boolean maps, so a cleared-to-`false` marker must not read as unread.
@@ -275,6 +279,57 @@ describe('hasUnreadAgentCompletionForTerminalTab', () => {
hasUnreadAgentCompletionForTerminalTab({ [`${TAB_ID}:${FIRST_LEAF_ID}`]: false }, TAB_ID)
).toBe(false)
})
it('preserves first-colon ownership for legacy and malformed pane keys', () => {
expect(hasUnreadAgentCompletionForTerminalTab({ [TAB_ID]: true }, TAB_ID)).toBe(true)
expect(hasUnreadAgentCompletionForTerminalTab({ [`${TAB_ID}:3`]: true }, TAB_ID)).toBe(true)
expect(
hasUnreadAgentCompletionForTerminalTab(
{ [`${TAB_ID}:${FIRST_LEAF_ID}:suffix`]: true },
TAB_ID
)
).toBe(true)
expect(
hasUnreadAgentCompletionForTerminalTab({ [`${TAB_ID}0:${SECOND_LEAF_ID}`]: true }, TAB_ID)
).toBe(false)
})
it('changes only the owning tab when immutable marker snapshots add and clear unread', () => {
const before = { [`tab-2:${SECOND_LEAF_ID}`]: true }
const added = { ...before, [`${TAB_ID}:${FIRST_LEAF_ID}`]: true }
const cleared = { ...added, [`${TAB_ID}:${FIRST_LEAF_ID}`]: false }
expect(hasUnreadAgentCompletionForTerminalTab(before, TAB_ID)).toBe(false)
expect(hasUnreadAgentCompletionForTerminalTab(added, TAB_ID)).toBe(true)
expect(hasUnreadAgentCompletionForTerminalTab(cleared, TAB_ID)).toBe(false)
expect(hasUnreadAgentCompletionForTerminalTab(before, 'tab-2')).toBe(true)
expect(hasUnreadAgentCompletionForTerminalTab(added, 'tab-2')).toBe(true)
expect(hasUnreadAgentCompletionForTerminalTab(cleared, 'tab-2')).toBe(true)
})
it('indexes one immutable marker snapshot once across all mounted tab lookups', () => {
const ownKeys = vi.fn(Reflect.ownKeys)
let valueReads = 0
const markers: Record<string, true> = Object.fromEntries(
Array.from({ length: 1_000 }, (_, index) => [`owner-${index}:leaf`, true] as const)
)
const unread = new Proxy<Record<string, true>>(markers, {
ownKeys,
get: (target, property, receiver) => {
valueReads += 1
return Reflect.get(target, property, receiver)
}
})
for (let publication = 0; publication < 5; publication += 1) {
for (let tab = 0; tab < 500; tab += 1) {
expect(hasUnreadAgentCompletionForTerminalTab(unread, `absent-${tab}`)).toBe(false)
}
}
expect(ownKeys).toHaveBeenCalledTimes(1)
expect(valueReads).toBe(1_000)
})
})
describe('resolveTerminalTabAttentionBadge', () => {
@@ -256,29 +256,49 @@ export function terminalTabHasUnreadActivity({
)
}
// Why: production writes replace this map; WeakMap supports retained snapshots without pinning them.
let unreadAgentCompletionTabIdsBySnapshot = new WeakMap<
Record<string, boolean | undefined>,
ReadonlySet<string>
>()
function getUnreadAgentCompletionTabIds(
unreadAgentCompletionPanes: Record<string, boolean | undefined>
): ReadonlySet<string> {
const cached = unreadAgentCompletionTabIdsBySnapshot.get(unreadAgentCompletionPanes)
if (cached) {
return cached
}
// Why: every mounted tab runs this selector per store write; index each immutable marker snapshot once.
const tabIds = new Set<string>()
for (const paneKey of Object.keys(unreadAgentCompletionPanes)) {
if (!unreadAgentCompletionPanes[paneKey]) {
continue
}
const separatorIndex = paneKey.indexOf(':')
tabIds.add(separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex))
}
unreadAgentCompletionTabIdsBySnapshot.set(unreadAgentCompletionPanes, tabIds)
return tabIds
}
/** Match pane-level unread completion markers to their owning terminal tab. */
export function hasUnreadAgentCompletionForTerminalTab(
unreadAgentCompletionPanes: Record<string, boolean | undefined> | undefined,
tabId: string
): boolean {
for (const [paneKey, unread] of Object.entries(unreadAgentCompletionPanes ?? {})) {
// Why entries, not keys: the widened value type lets a cleared marker linger as `false`.
if (!unread) {
continue
}
// paneKey is `${tabId}:${leafId}` and tab ids never contain ":", so the
// prefix up to the first ":" is the owning tab id (see
// selectFloatingWorkspaceHasUnread). Prefix-match to keep legacy keys.
const separatorIndex = paneKey.indexOf(':')
const owningTabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex)
if (owningTabId === tabId) {
return true
}
}
return false
return unreadAgentCompletionPanes
? getUnreadAgentCompletionTabIds(unreadAgentCompletionPanes).has(tabId)
: false
}
/** Test-only: clear the memoized per-tab flag cache between cases. */
export function resetTerminalTabActivityFlagsCacheForTest(): void {
flagsCache = null
}
/** Test-only: clear the unread marker snapshot index between cases. */
export function resetUnreadAgentCompletionTabIdsCacheForTest(): void {
unreadAgentCompletionTabIdsBySnapshot = new WeakMap()
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
import {
createDeferred,
flushAsyncTicks,
processResult,
useAgentCompletionCoordinatorLifecycle
} from './agent-completion-coordinator-test-harness'
import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection'
describe('agent completion coordinator queued inspections', () => {
useAgentCompletionCoordinatorLifecycle()
it('drops inspections queued by a disposed coordinator before starting live work', async () => {
const blockers = Array.from({ length: 4 }, () =>
createDeferred<RuntimeTerminalProcessInspection>()
)
const blockerInspectors = blockers.map((inspection) => vi.fn(() => inspection.promise))
const blockerCoordinators = blockerInspectors.map((inspectProcess, index) =>
createAgentCompletionCoordinator({
paneKey: `tab-1:blocked-${index}`,
getPtyId: () => `pty-blocked-${index}`,
getSettings: () => null,
inspectProcess,
dispatchCompletion: vi.fn(),
isLive: () => true
})
)
blockerCoordinators.forEach((coordinator) => coordinator.startProcessTracking())
await vi.advanceTimersByTimeAsync(2_000)
expect(
blockerInspectors.every((inspectProcess) => inspectProcess.mock.calls.length === 1)
).toBe(true)
const staleInspectProcesses = Array.from({ length: 8 }, () =>
vi.fn(async () => processResult(null, false))
)
const staleCoordinators = staleInspectProcesses.map((inspectProcess, index) =>
createAgentCompletionCoordinator({
paneKey: `tab-1:stale-${index}`,
getPtyId: () => `pty-stale-${index}`,
getSettings: () => null,
inspectProcess,
dispatchCompletion: vi.fn(),
isLive: () => true
})
)
const liveInspectProcess = vi.fn(async () => processResult(null, false))
const liveCoordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:live',
getPtyId: () => 'pty-live',
getSettings: () => null,
inspectProcess: liveInspectProcess,
dispatchCompletion: vi.fn(),
isLive: () => true
})
for (const [index, coordinator] of staleCoordinators.entries()) {
coordinator.observeTitle(`Codex working ${index}`)
coordinator.observeTitle(`~/stale-${index}`)
}
liveCoordinator.observeTitle('Codex working')
liveCoordinator.observeTitle('~/live')
staleCoordinators.forEach((coordinator) => coordinator.dispose())
blockers.forEach((inspection) => inspection.resolve(processResult(null, false)))
await flushAsyncTicks()
// Existing 100ms pump admits live work after blockers release.
await vi.advanceTimersByTimeAsync(100)
await flushAsyncTicks()
expect(
staleInspectProcesses.every((inspectProcess) => inspectProcess.mock.calls.length === 0)
).toBe(true)
expect(liveInspectProcess).toHaveBeenCalledTimes(1)
blockerCoordinators.forEach((coordinator) => coordinator.dispose())
liveCoordinator.dispose()
})
})
@@ -146,6 +146,7 @@ export function createAgentCompletionProcessMonitor({
const pendingTitleIdAtRequest = priority === 'pending-title' ? pendingTitle.get()?.id : null
enqueueAgentProcessInspection({
priority,
canRun: () => !state.disposed,
run: async () => {
let inspectedRecognizedAgent = false
let inspectionSucceeded = false
@@ -2,6 +2,7 @@ export type InspectionPriority = 'cadence' | 'pending-title'
type InspectionTask = {
priority: InspectionPriority
canRun: () => boolean
run: () => Promise<void>
}
@@ -37,6 +38,16 @@ function scheduleInspectionPump(delayMs = 0): void {
}
function pumpInspectionQueue(): void {
// Drop disposed tasks before slot/rate accounting.
for (let index = inspectionQueue.length - 1; index >= 0; index -= 1) {
const task = inspectionQueue[index]
if (task && !task.canRun()) {
inspectionQueue.splice(index, 1)
}
}
if (inspectionQueue.length === 0) {
return
}
const now = Date.now()
if (!canStartInspection(now)) {
scheduleInspectionPump(100)
@@ -115,6 +115,30 @@ describe('waitForStableStartupGrid', () => {
expect(onSettled).toHaveBeenCalledWith({ cols: 88, rows: 50 })
})
it('stops polling at the default cap when the external split gate never opens', () => {
const scheduler = createFrameScheduler()
const onSettled = vi.fn()
const measure = vi.fn(() => ({ cols: 88, rows: 50 }))
waitForStableStartupGrid({
isAlive: () => true,
isReadyToSettle: () => false,
measure,
onSettled,
requestFrame: scheduler.requestFrame,
cancelFrame: scheduler.cancelFrame
})
expect(scheduler.run(119)).toBe(119)
expect(scheduler.pending()).toBe(1)
expect(onSettled).not.toHaveBeenCalled()
expect(scheduler.run(10)).toBe(1)
expect(scheduler.pending()).toBe(0)
expect(measure).toHaveBeenCalledTimes(120)
expect(onSettled).toHaveBeenCalledWith({ cols: 88, rows: 50 })
})
it('uses the latest usable grid at the frame cap when dimensions keep changing', () => {
const scheduler = createFrameScheduler()
const onSettled = vi.fn()
@@ -13,6 +13,7 @@ export type TerminalStartupGridSettleOptions = {
minFrames?: number
stableFrames?: number
maxFrames?: number
maxReadinessWaitFrames?: number
}
export type TerminalStartupGridSettleHandle = {
@@ -22,6 +23,8 @@ export type TerminalStartupGridSettleHandle = {
const DEFAULT_MIN_FRAMES = 6
const DEFAULT_STABLE_FRAMES = 2
const DEFAULT_MAX_FRAMES = 12
// Why: ten settle windows tolerate slow split layout without permitting permanent polling.
const DEFAULT_MAX_READINESS_WAIT_FRAMES = DEFAULT_MAX_FRAMES * 10
function usableDimensions(
dimensions: TerminalStartupGridDimensions | null
@@ -42,6 +45,10 @@ export function waitForStableStartupGrid(
const minFrames = Math.max(1, options.minFrames ?? DEFAULT_MIN_FRAMES)
const stableFrames = Math.max(1, options.stableFrames ?? DEFAULT_STABLE_FRAMES)
const maxFrames = Math.max(minFrames, options.maxFrames ?? DEFAULT_MAX_FRAMES)
const maxReadinessWaitFrames = Math.max(
1,
options.maxReadinessWaitFrames ?? DEFAULT_MAX_READINESS_WAIT_FRAMES
)
let frame = 0
let stableFrameCount = 0
let previous: TerminalStartupGridDimensions | null = null
@@ -50,6 +57,7 @@ export function waitForStableStartupGrid(
let pendingFrame: number | null = null
let cancelled = false
let readyFrame = 0
let latestReadinessWaitUsable: TerminalStartupGridDimensions | null = null
const usesReadinessGate = options.isReadyToSettle !== undefined
const settle = (dimensions: TerminalStartupGridDimensions | null): void => {
@@ -73,6 +81,13 @@ export function waitForStableStartupGrid(
const measured = options.measure()
const readyToSettle = options.isReadyToSettle?.() ?? true
if (!readyToSettle) {
if (usableDimensions(measured)) {
latestReadinessWaitUsable = measured
}
if (frame >= maxReadinessWaitFrames) {
settle(latestReadinessWaitUsable)
return
}
previous = null
stableFrameCount = 0
observedGridChange = false
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import {
selectAutomationAgentStatusEntryChange,
UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY
} from './automation-agent-status-entry-change'
function makeEntry(paneKey: string): AgentStatusEntry {
return {
paneKey,
state: 'working',
prompt: 'turn',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: []
}
}
describe('selectAutomationAgentStatusEntryChange', () => {
it('reads only the target and skips an unchanged entry', () => {
const targetPaneKey = 'target-tab:leaf'
const targetEntry = makeEntry(targetPaneKey)
const entries = Object.fromEntries(
Array.from({ length: 499 }, (_, index) => {
const paneKey = `other-tab:${index}`
return [paneKey, makeEntry(paneKey)]
})
)
entries[targetPaneKey] = targetEntry
let enumerations = 0
const measuredEntries = new Proxy(entries, {
ownKeys: (target) => {
enumerations += 1
return Reflect.ownKeys(target)
}
})
expect(selectAutomationAgentStatusEntryChange(measuredEntries, targetPaneKey, undefined)).toBe(
targetEntry
)
expect(
selectAutomationAgentStatusEntryChange(measuredEntries, targetPaneKey, targetEntry)
).toBe(UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY)
expect(enumerations).toBe(0)
})
it('reports removal and ignores inherited or non-enumerable entries', () => {
const targetPaneKey = 'target-tab:leaf'
const previousEntry = makeEntry(targetPaneKey)
const inheritedEntries = Object.create({ [targetPaneKey]: previousEntry }) as Record<
string,
AgentStatusEntry
>
const nonEnumerableEntries = Object.defineProperty({}, targetPaneKey, {
value: previousEntry
}) as Record<string, AgentStatusEntry>
expect(selectAutomationAgentStatusEntryChange({}, targetPaneKey, previousEntry)).toBeUndefined()
expect(selectAutomationAgentStatusEntryChange(inheritedEntries, targetPaneKey, undefined)).toBe(
UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY
)
expect(
selectAutomationAgentStatusEntryChange(nonEnumerableEntries, targetPaneKey, undefined)
).toBe(UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY)
})
})
@@ -0,0 +1,15 @@
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
export const UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY = Symbol('unchanged-agent-status-entry')
export function selectAutomationAgentStatusEntryChange(
entries: Readonly<Record<string, AgentStatusEntry>>,
targetPaneKey: string,
previousEntry: AgentStatusEntry | undefined
): AgentStatusEntry | undefined | typeof UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY {
const entry = Object.prototype.propertyIsEnumerable.call(entries, targetPaneKey)
? entries[targetPaneKey]
: undefined
// Why: status writes replace entries; unchanged identity proves an unrelated publication.
return entry === previousEntry ? UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY : entry
}
@@ -8,7 +8,11 @@ import type {
AutomationPrecheckResult,
AutomationRun
} from '../../../shared/automations-types'
import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types'
import type { AgentStateHistoryEntry, AgentStatusEntry } from '../../../shared/agent-status-types'
import {
selectAutomationAgentStatusEntryChange,
UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY
} from './automation-agent-status-entry-change'
import type { Worktree } from '../../../shared/worktree/types'
type MarkDispatchResult = (result: AutomationDispatchResult) => Promise<void>
@@ -143,53 +147,60 @@ export function createAutomationDispatchCompletion(args: {
): void => {
let sawWorkingAfterStart = false
let observedStateHistory: AgentStateHistoryEntry[] = []
let observedEntry: AgentStatusEntry | undefined
const checkCurrentStatus = (): void => {
const { agentStatusByPaneKey } = useAppStore.getState()
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (paneKey !== targetPaneKey || entry.updatedAt < startedAfter) {
const entryChange = selectAutomationAgentStatusEntryChange(
useAppStore.getState().agentStatusByPaneKey,
targetPaneKey,
observedEntry
)
if (entryChange === UNCHANGED_AUTOMATION_AGENT_STATUS_ENTRY) {
return
}
const entry = entryChange
observedEntry = entry
if (!entry || entry.updatedAt < startedAfter) {
return
}
const historyOverlap = getAgentStateHistoryOverlap(observedStateHistory, entry.stateHistory)
// Why: sawWorkingAfterStart stays monotonic — a recreated entry
// (transport loss, PTY exit, cap eviction) arrives with an empty
// stateHistory, so clearing it here would strand reuseSession runs
// with nothing left to re-derive the working edge from.
for (const historicalState of entry.stateHistory.slice(historyOverlap)) {
if (historicalState.startedAt < startedAfter) {
continue
}
const historyOverlap = getAgentStateHistoryOverlap(observedStateHistory, entry.stateHistory)
// Why: sawWorkingAfterStart stays monotonic — a recreated entry
// (transport loss, PTY exit, cap eviction) arrives with an empty
// stateHistory, so clearing it here would strand reuseSession runs
// with nothing left to re-derive the working edge from.
for (const historicalState of entry.stateHistory.slice(historyOverlap)) {
if (historicalState.startedAt < startedAfter) {
continue
}
if (historicalState.state === 'working') {
sawWorkingAfterStart = true
}
if (
historicalState.state === 'done' &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
// Why: this `done` already rolled out of the live entry, so its output
// survives only in the entry-level completed slot.
latestAssistantMessage =
entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
return
}
}
observedStateHistory = [...entry.stateHistory]
if (entry.state === 'working') {
if (historicalState.state === 'working') {
sawWorkingAfterStart = true
}
if (
entry.state === 'done' &&
// Why: a session-boundary done is the agent CONNECTING (Claude SessionStart
// fires at launch, before the argv prompt submits) — completing here would
// close the tab and record an empty run result.
entry.sessionBoundary !== true &&
historicalState.state === 'done' &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage = entry.lastAssistantMessage?.trim() || latestAssistantMessage
// Why: this `done` already rolled out of the live entry, so its output
// survives only in the entry-level completed slot.
latestAssistantMessage =
entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
return
}
}
observedStateHistory = [...entry.stateHistory]
if (entry.state === 'working') {
sawWorkingAfterStart = true
}
if (
entry.state === 'done' &&
// Why: a session-boundary done is the agent CONNECTING (Claude SessionStart
// fires at launch, before the argv prompt submits) — completing here would
// close the tab and record an empty run result.
entry.sessionBoundary !== true &&
(!options?.requireWorkingAfterStart || sawWorkingAfterStart)
) {
latestAssistantMessage = entry.lastAssistantMessage?.trim() || latestAssistantMessage
handleAgentDone()
}
}
// Why: Codex/Claude completion normally arrives through the global
// hook IPC listener, not the hidden PTY OSC fallback.
@@ -0,0 +1,30 @@
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
const TARGET_PANE_KEY = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d'
export function countUnchangedObserverHistoryReads(
state: { agentStatusByPaneKey: Record<string, AgentStatusEntry> },
subscriber: (() => void) | null
): number {
if (!subscriber) {
throw new Error('agent status observer was not registered')
}
let historyReads = 0
state.agentStatusByPaneKey = {
[TARGET_PANE_KEY]: {
paneKey: TARGET_PANE_KEY,
state: 'working',
prompt: 'turn',
updatedAt: Date.now() + 1,
stateStartedAt: Date.now() + 1,
get stateHistory() {
historyReads += 1
return []
}
}
}
subscriber()
historyReads = 0
subscriber()
return historyReads
}
@@ -13,6 +13,7 @@ import type {
AgentStatusBatchEvent,
PendingAgentStatusEvent
} from './agent-status-bridge-types'
import { shouldRetryPendingAgentStatusesAfterStoreUpdate } from './agent-status-pending-retry-gate'
const PENDING_AGENT_STATUS_RETRY_MS = 100
const PENDING_AGENT_STATUS_TTL_MS = 15_000
@@ -94,8 +95,8 @@ export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatu
}
} finally {
isFlushingAgentStatuses = false
schedulePendingAgentStatusFlush()
}
schedulePendingAgentStatusFlush()
}
const applyAgentStatus = createAgentStatusEventApplicator({
@@ -261,7 +262,13 @@ export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatu
requestAgentStatusSnapshotIfReady()
const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => {
requestAgentStatusSnapshotIfReady()
flushPendingAgentStatuses()
// Why: the timer covers module-owned rekeys; unrelated store writes cannot change attribution and must not rebuild its routing index.
if (
pendingAgentStatusEvents.length > 0 &&
shouldRetryPendingAgentStatusesAfterStoreUpdate(state, previousState)
) {
flushPendingAgentStatuses()
}
syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState)
})
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusBatchUpdate } from '../../store/slices/agent-status'
import {
buildStoreState,
FUTURE_LEAF_ID,
FUTURE_PANE_KEY,
type AgentStatusSetData,
type StoreLike,
type StoreSubscribeListener
} from '../ipc-events-agent-status-store-test-fixtures'
import { shouldRetryPendingAgentStatusesAfterStoreUpdate } from './agent-status-pending-retry-gate'
type RetryState = Parameters<typeof shouldRetryPendingAgentStatusesAfterStoreUpdate>[0]
function createRetryState(): RetryState {
return {
workspaceSessionReady: true,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {},
worktreesByRepo: {},
repos: [],
recentlyClosedAgentStatusTabIds: {},
recentlyRetiredAgentStatusPaneKeys: {}
}
}
type PendingRetryHarness = {
bridge: { disposeAsyncState: () => void; unsubscribeStore: () => void }
emitPendingStatus: () => void
publish: (mutate: (state: StoreLike) => void) => void
setAgentStatuses: ReturnType<typeof vi.fn>
transactAgentStatuses: ReturnType<typeof vi.fn>
}
async function createPendingRetryHarness(): Promise<PendingRetryHarness> {
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const setAgentStatuses = vi.fn((updates: readonly AgentStatusBatchUpdate[]) =>
updates.map(() => true)
)
const storeState = buildStoreState({
setAgentStatuses,
workspaceSessionReady: true,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
settings: { terminalFontSize: 13, notifications: { enabled: false } }
})
const transactImplementation = storeState.transactAgentStatuses as (...args: unknown[]) => unknown
const transactAgentStatuses = vi.fn(transactImplementation)
storeState.transactAgentStatuses = transactAgentStatuses
vi.doMock('../../store', () => ({
useAppStore: {
subscribe: vi.fn((listener: StoreSubscribeListener) => {
subscribeListenerRef.current = listener
return () => {
subscribeListenerRef.current = null
}
}),
getState: () => storeState
}
}))
vi.doMock('../agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification: vi.fn(),
syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn()
}))
vi.stubGlobal('window', {
api: {
agentStatus: {
onSet: (listener: (data: AgentStatusSetData) => void) => {
onSetListenerRef.current = listener
return () => {
onSetListenerRef.current = null
}
}
}
}
})
const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge')
const bridge = registerAgentStatusIpcBridge([])
if (!subscribeListenerRef.current || !onSetListenerRef.current) {
throw new Error('Expected agent-status bridge listeners')
}
return {
bridge,
emitPendingStatus: () => {
onSetListenerRef.current?.({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'pending attribution',
agentType: 'claude',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_700_000_000_000
})
},
publish: (mutate) => {
const previousState = { ...storeState }
mutate(storeState)
subscribeListenerRef.current?.(storeState, previousState)
},
setAgentStatuses,
transactAgentStatuses
}
}
describe('agent status pending retry gate', () => {
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(1_700_000_100_000)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('tracks every store-owned input that can resolve or retire pending attribution', () => {
const previous = createRetryState()
const changes: RetryState[] = [
{ ...previous, workspaceSessionReady: false },
{ ...previous, tabsByWorktree: {} },
{ ...previous, unifiedTabsByWorktree: {} },
{ ...previous, terminalLayoutsByTabId: {} },
{ ...previous, worktreesByRepo: {} },
{ ...previous, repos: [] },
{ ...previous, recentlyClosedAgentStatusTabIds: {} },
{ ...previous, recentlyRetiredAgentStatusPaneKeys: {} }
]
expect(shouldRetryPendingAgentStatusesAfterStoreUpdate({ ...previous }, previous)).toBe(false)
for (const current of changes) {
expect(shouldRetryPendingAgentStatusesAfterStoreUpdate(current, previous)).toBe(true)
}
})
it('skips retry transactions for unrelated publications and retries on hydration', async () => {
const harness = await createPendingRetryHarness()
harness.emitPendingStatus()
for (let updateIndex = 0; updateIndex < 2_400; updateIndex += 1) {
harness.publish((state) => {
state.cosmeticUpdateIndex = updateIndex
})
}
expect(harness.transactAgentStatuses).not.toHaveBeenCalled()
harness.publish((state) => {
state.tabsByWorktree = {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future' }]
}
state.terminalLayoutsByTabId = {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
}
})
expect(harness.transactAgentStatuses).toHaveBeenCalledTimes(1)
expect(harness.setAgentStatuses).toHaveBeenCalledWith([
expect.objectContaining({ paneKey: FUTURE_PANE_KEY })
])
vi.advanceTimersByTime(100)
expect(harness.transactAgentStatuses).toHaveBeenCalledTimes(1)
harness.bridge.unsubscribeStore()
harness.bridge.disposeAsyncState()
})
it('keeps the timer fallback when routing references stay unchanged', async () => {
const harness = await createPendingRetryHarness()
harness.emitPendingStatus()
harness.publish((state) => {
state.cosmeticUpdateIndex = 1
})
expect(harness.transactAgentStatuses).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(harness.transactAgentStatuses).toHaveBeenCalledTimes(1)
expect(harness.setAgentStatuses).toHaveBeenCalledWith([])
harness.bridge.unsubscribeStore()
harness.bridge.disposeAsyncState()
})
})
@@ -0,0 +1,29 @@
import type { AppState } from '../../store/types'
type AgentStatusPendingRetryState = Pick<
AppState,
| 'workspaceSessionReady'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'terminalLayoutsByTabId'
| 'worktreesByRepo'
| 'repos'
| 'recentlyClosedAgentStatusTabIds'
| 'recentlyRetiredAgentStatusPaneKeys'
>
export function shouldRetryPendingAgentStatusesAfterStoreUpdate(
current: AgentStatusPendingRetryState,
previous: AgentStatusPendingRetryState
): boolean {
return (
current.workspaceSessionReady !== previous.workspaceSessionReady ||
current.tabsByWorktree !== previous.tabsByWorktree ||
current.unifiedTabsByWorktree !== previous.unifiedTabsByWorktree ||
current.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId ||
current.worktreesByRepo !== previous.worktreesByRepo ||
current.repos !== previous.repos ||
current.recentlyClosedAgentStatusTabIds !== previous.recentlyClosedAgentStatusTabIds ||
current.recentlyRetiredAgentStatusPaneKeys !== previous.recentlyRetiredAgentStatusPaneKeys
)
}

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