perf(worktree): retain an idle preparation for the active composer target

This commit is contained in:
Neil
2026-09-05 02:42:50 -07:00
parent 27bbd61b96
commit 50071fc362
16 changed files with 764 additions and 17 deletions
@@ -27,6 +27,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:adoptProvisionedRoot',
'worktrees:prefetchCreateBase',
'worktrees:prepareCreateCheckout',
'worktrees:setCreateStandby',
'worktrees:resolvePrBase',
'worktrees:resolveMrBase',
'worktrees:remove',
+4 -1
View File
@@ -26,6 +26,7 @@ registerDetectedWorktreeScanInvalidation()
// Why not module scope like the invalidation above: this needs `mainWindow`/`store`, which only
// exist once a window is attached, and must track the current ones across re-registration.
let disposeWorktreeStandbys: (() => void) | undefined
let disposeSparseCheckoutCacheInvalidation: (() => void) | undefined
const WORKTREE_HANDLER_CHANNELS = [
@@ -40,6 +41,7 @@ const WORKTREE_HANDLER_CHANNELS = [
'worktrees:adoptProvisionedRoot',
'worktrees:prefetchCreateBase',
'worktrees:prepareCreateCheckout',
'worktrees:setCreateStandby',
'worktrees:resolvePrBase',
'worktrees:resolveMrBase',
'worktrees:remove',
@@ -87,7 +89,8 @@ export function registerWorktreeHandlers(
registerWorktreeCatalogHandlers(context)
registerHostCatalogHandlers(context)
registerDetectedWorktreeHandlers(context)
registerWorktreePrefetchHandler(context)
disposeWorktreeStandbys?.()
disposeWorktreeStandbys = registerWorktreePrefetchHandler(context)
registerWorktreeCreateHandlers(context)
registerReviewBaseHandlers(context)
registerWorktreeRemovalHandlers(context)
@@ -1,12 +1,66 @@
import { ipcMain } from 'electron'
import { prepareWorktreeCreateStandby } from '../../../worktree-create-standby'
import { ipcMain, type WebContents } from 'electron'
import { createWorktreeStandbyOwner } from '../../../worktree-create-standby-owner'
import {
prepareWorktreeCreateStandby,
retainWorktreeCreateStandby
} from '../../../worktree-create-standby'
import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch'
import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation'
import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void {
export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): () => void {
const { store, runtime } = context
const owners = new Map<
WebContents,
{ controller: ReturnType<typeof createWorktreeStandbyOwner>; dispose: () => void }
>()
ipcMain.handle(
'worktrees:setCreateStandby',
async (event, args: { repoId: string | null; baseBranch?: string }) => {
const sender = event.sender
if (sender.isDestroyed()) {
return
}
let owner = owners.get(sender)
if (!owner) {
if (!args.repoId) {
return
}
const controller = createWorktreeStandbyOwner()
const dispose = () => {
controller.close()
owners.delete(sender)
sender.removeListener('destroyed', dispose)
sender.removeListener('render-process-gone', dispose)
sender.removeListener('did-start-navigation', onNavigation)
}
const onNavigation = (
_event: Electron.Event,
_url: string,
isInPlace: boolean,
isMainFrame: boolean
): void => {
if (isMainFrame && !isInPlace) {
dispose()
}
}
owner = { controller, dispose }
owners.set(sender, owner)
sender.once('destroyed', dispose)
sender.once('render-process-gone', dispose)
sender.on('did-start-navigation', onNavigation)
}
await owner.controller.set(
args.repoId
? async () => {
const repo = store.getRepo(args.repoId!)
return repo ? retainWorktreeCreateStandby(store, repo, args.baseBranch) : () => {}
}
: undefined
)
}
)
ipcMain.handle(
'worktrees:prepareCreateCheckout',
@@ -44,4 +98,9 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo
}
}
)
return () => {
for (const owner of owners.values()) {
owner.dispose()
}
}
}
@@ -1,14 +1,19 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
standby: vi.fn(),
retain: vi.fn(),
prefetch: vi.fn(),
getRepo: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../../../worktree-create-standby', () => ({ prepareWorktreeCreateStandby: mocks.standby }))
vi.mock('../../../worktree-create-standby', () => ({
prepareWorktreeCreateStandby: mocks.standby,
retainWorktreeCreateStandby: mocks.retain
}))
vi.mock('../../../worktree-create-base-prefetch', () => ({
prefetchWorktreeCreateBase: mocks.prefetch
}))
@@ -22,10 +27,11 @@ import { registerWorktreePrefetchHandler } from './register-worktree-prefetch-ha
const context = { store: { getRepo: mocks.getRepo }, runtime: {} } as unknown as WorktreeIpcContext
const repo = { id: 'repo', path: '/repo' }
let standby: (event: null, args: { repoId: string; baseBranch?: string }) => Promise<void>
let dispose: () => void
beforeEach(() => {
vi.resetAllMocks()
mocks.getRepo.mockReturnValue(repo)
registerWorktreePrefetchHandler(context)
dispose = registerWorktreePrefetchHandler(context)
standby = mocks.handle.mock.calls.find(
([channel]) => channel === 'worktrees:prepareCreateCheckout'
)![1]
@@ -48,3 +54,41 @@ it('contains speculative failure without falling back to a fetch', async () => {
await expect(standby(null, { repoId: 'repo' })).resolves.toBeUndefined()
expect(mocks.prefetch).not.toHaveBeenCalled()
})
afterEach(() => dispose())
it.each(['destroy', 'crash', 'reload', 'register cleanup'] as const)(
'releases held checkout on %s',
async (action) => {
const release = vi.fn()
mocks.retain.mockResolvedValue(release)
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false })
const handler = mocks.handle.mock.calls.find(
([name]) => name === 'worktrees:setCreateStandby'
)![1]
await handler({ sender }, { repoId: 'repo', baseBranch: 'chosen' })
expect(mocks.retain).toHaveBeenCalledExactlyOnceWith(context.store, repo, 'chosen')
if (action === 'destroy') {
sender.emit('destroyed')
} else {
dispose()
}
expect(release).toHaveBeenCalledOnce()
expect(sender.listenerCount('destroyed')).toBe(0)
expect(sender.listenerCount('render-process-gone')).toBe(0)
expect(sender.listenerCount('did-start-navigation')).toBe(0)
}
)
it('releases on hide without starting another preparation', async () => {
const release = vi.fn()
mocks.retain.mockResolvedValue(release)
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false })
const handler = mocks.handle.mock.calls.find(
([name]) => name === 'worktrees:setCreateStandby'
)![1]
await handler({ sender }, { repoId: 'repo' })
await handler({ sender }, { repoId: null })
expect(release).toHaveBeenCalledOnce()
expect(mocks.retain).toHaveBeenCalledOnce()
})
@@ -144,6 +144,29 @@ export function takePreparation(entry: PreparationEntry): void {
clearTimeout(entry.expiration)
}
const preparationHolds = new WeakMap<PreparationEntry, number>()
export function holdPreparation(entry: PreparationEntry): () => void {
if (preparations.get(entry.key) !== entry) {
return () => {}
}
clearTimeout(entry.expiration)
preparationHolds.set(entry, (preparationHolds.get(entry) ?? 0) + 1)
let released = false
return () => {
if (released) {
return
}
released = true
const remaining = (preparationHolds.get(entry) ?? 1) - 1
preparationHolds.set(entry, remaining)
if (remaining === 0 && preparations.get(entry.key) === entry) {
entry.expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS)
entry.expiration.unref()
}
}
}
export function startPreparation({
repoPath,
workspaceRoot,
+39 -4
View File
@@ -12,6 +12,7 @@ import { preparationPathKey, selectPreparationForCreate } from './worktree-creat
import {
_resetPreparationPoolForTests,
findPreparation,
holdPreparation,
hasPendingPreparations,
listPreparations,
startPreparation,
@@ -71,11 +72,11 @@ function canonicalBaseRef(
)
}
export async function prepareWorktreeCreateForRepo(
async function getOrStartPreparationForRepo(
store: Store,
repo: Repo,
baseBranch: string
): Promise<void> {
): Promise<PreparationEntry | undefined> {
if (repo.connectionId || isFolderRepo(repo)) {
return
}
@@ -96,16 +97,50 @@ export async function prepareWorktreeCreateForRepo(
options.wslDistro ?? ''
)
if (existing) {
return existing.ready
return existing
}
return startPreparation({
void startPreparation({
repoPath: repo.path,
workspaceRoot,
baseBranch,
canonicalBase,
options
})
return findPreparation(
preparationPathKey(repo.path),
preparationPathKey(workspaceRoot),
canonicalBase,
options.wslDistro ?? ''
)
}
export async function prepareWorktreeCreateForRepo(
store: Store,
repo: Repo,
baseBranch: string
): Promise<void> {
const entry = await getOrStartPreparationForRepo(store, repo, baseBranch)
await entry?.ready
}
export async function retainWorktreeCreateForRepo(
store: Store,
repo: Repo,
baseBranch: string
): Promise<() => void> {
const entry = await getOrStartPreparationForRepo(store, repo, baseBranch)
if (!entry) {
return () => {}
}
const release = holdPreparation(entry)
try {
await entry.ready
return release
} catch (error) {
release()
throw error
}
}
type ClaimedPreparation =
@@ -0,0 +1,63 @@
import { expect, it, vi } from 'vitest'
import { createWorktreeStandbyOwner } from './worktree-create-standby-owner'
function deferred() {
let resolve!: (release: () => void) => void
const promise = new Promise<() => void>((done) => {
resolve = done
})
return { promise, resolve }
}
it('serializes disk work and skips targets replaced while queued', async () => {
const first = createWorktreeStandbyOwner()
const second = createWorktreeStandbyOwner()
const pending = deferred()
const releaseFirst = vi.fn()
const releaseLast = vi.fn()
const start = vi.fn(() => pending.promise)
const skipped = vi.fn(async () => () => {})
const last = vi.fn(async () => releaseLast)
const active = first.set(start)
await Promise.resolve()
expect(start).toHaveBeenCalledOnce()
const obsolete = second.set(skipped)
const latest = second.set(last)
await Promise.resolve()
expect(last).not.toHaveBeenCalled()
pending.resolve(releaseFirst)
await Promise.all([active, obsolete, latest])
expect(skipped).not.toHaveBeenCalled()
expect(last).toHaveBeenCalledOnce()
first.close()
second.close()
expect(releaseFirst).toHaveBeenCalledOnce()
expect(releaseLast).toHaveBeenCalledOnce()
})
it('releases a preparation that finishes after its owner is closed', async () => {
const owner = createWorktreeStandbyOwner()
const pending = deferred()
const release = vi.fn()
const work = owner.set(() => pending.promise)
await Promise.resolve()
owner.close()
pending.resolve(release)
await work
expect(release).toHaveBeenCalledOnce()
const prepare = vi.fn()
await owner.set(prepare)
expect(prepare).not.toHaveBeenCalled()
})
it('releases on hide and does not poison later work after a failed preparation', async () => {
const owner = createWorktreeStandbyOwner()
await owner.set(async () => {
throw new Error('disk unavailable')
})
const release = vi.fn()
await owner.set(async () => release)
await owner.set()
owner.close()
expect(release).toHaveBeenCalledOnce()
})
+42
View File
@@ -0,0 +1,42 @@
type ReleaseStandby = () => void
let standbyQueue: Promise<void> = Promise.resolve()
export function createWorktreeStandbyOwner() {
let revision = 0
let closed = false
let release: ReleaseStandby | undefined
return {
set(prepare?: () => Promise<ReleaseStandby>): Promise<void> {
const current = ++revision
release?.()
release = undefined
if (closed || !prepare) {
return Promise.resolve()
}
const job = standbyQueue.then(async () => {
if (closed || revision !== current) {
return
}
try {
const prepared = await prepare()
if (closed || revision !== current) {
prepared()
} else {
release = prepared
}
} catch {
// Speculative failure leaves ordinary Create responsible for recovery.
}
})
standbyQueue = job
return job
},
close(): void {
closed = true
revision++
release?.()
release = undefined
}
}
}
+2 -1
View File
@@ -30,7 +30,8 @@ beforeEach(() => {
describe('checkout-only standby', () => {
it.each([
{ ...repo, kind: 'folder' as const },
{ ...repo, connectionId: 'ssh' }
{ ...repo, connectionId: 'ssh' },
{ ...repo, executionHostId: 'runtime:other' as const }
])('does no local work for an ineligible repo', async (target) => {
await prepareWorktreeCreateStandby(store, target)
expect(mocks.options).not.toHaveBeenCalled()
+29 -5
View File
@@ -1,18 +1,22 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { getBaseRefDefault } from './git/repo'
import { hasLocalWorktreeBaseRef } from './git/worktree-base-ref-probe'
import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options'
import { resolveWorktreeCreateBase } from './worktree-create-base'
import { prepareWorktreeCreateForRepo } from './worktree-create-preparation'
import {
prepareWorktreeCreateForRepo,
retainWorktreeCreateForRepo
} from './worktree-create-preparation'
export async function prepareWorktreeCreateStandby(
async function resolveStandbyBase(
store: Store,
repo: Repo,
requestedBaseBranch?: string
): Promise<void> {
if (repo.connectionId || isFolderRepo(repo)) {
): Promise<string | undefined> {
if (getRepoExecutionHostId(repo) !== 'local' || isFolderRepo(repo)) {
return
}
const options = getLocalProjectWorktreeGitOptions(store, repo)
@@ -26,5 +30,25 @@ export async function prepareWorktreeCreateStandby(
if (!baseBranch || !(await hasLocalWorktreeBaseRef(repo.path, baseBranch, options))) {
return
}
await prepareWorktreeCreateForRepo(store, repo, baseBranch)
return baseBranch
}
export async function prepareWorktreeCreateStandby(
store: Store,
repo: Repo,
requestedBaseBranch?: string
): Promise<void> {
const base = await resolveStandbyBase(store, repo, requestedBaseBranch)
if (base) {
await prepareWorktreeCreateForRepo(store, repo, base)
}
}
export async function retainWorktreeCreateStandby(
store: Store,
repo: Repo,
requestedBaseBranch?: string
): Promise<() => void> {
const base = await resolveStandbyBase(store, repo, requestedBaseBranch)
return base ? retainWorktreeCreateForRepo(store, repo, base) : () => {}
}
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ prepare: vi.fn(), discard: vi.fn() }))
vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => {}) }))
vi.mock('./git/worktree-create-preparation', () => ({
prepareWorktreeCreateCheckout: mocks.prepare
}))
vi.mock('./worktree-create-preparation-stale-cleanup', () => ({
cleanupStalePreparations: vi.fn(async () => {}),
hasPendingStalePreparationCleanup: () => false,
resetStalePreparationCleanupForTests: vi.fn(async () => {})
}))
vi.mock('./worktree-preparation-discard-retry', () => ({
discardPreparationWithRetry: mocks.discard,
trackPreparationDiscard: vi.fn(),
resetPendingPreparationDiscardsForTests: vi.fn(async () => {})
}))
import {
startPreparation,
listPreparations,
holdPreparation,
takePreparation,
_resetPreparationPoolForTests,
WORKTREE_CREATE_PREPARATION_TTL_MS as ttl,
WORKTREE_CREATE_PREPARATION_LIMIT as limit
} from './worktree-create-preparation-pool'
const args = {
repoPath: '/repo',
workspaceRoot: '/workspace',
baseBranch: 'main',
canonicalBase: 'refs/heads/main',
options: {}
}
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
mocks.prepare.mockResolvedValue(undefined)
mocks.discard.mockResolvedValue(undefined)
})
afterEach(async () => {
await _resetPreparationPoolForTests()
vi.useRealTimers()
})
it('keeps one checkout through repeated TTLs without rebuilding and expires after final release', async () => {
await startPreparation(args)
const entry = listPreparations()[0]
const releaseFirst = holdPreparation(entry)
const releaseSecond = holdPreparation(entry)
await vi.advanceTimersByTimeAsync(ttl * 3)
expect(listPreparations()).toEqual([entry])
expect(mocks.prepare).toHaveBeenCalledOnce()
expect(mocks.discard).not.toHaveBeenCalled()
releaseFirst()
releaseFirst()
await vi.advanceTimersByTimeAsync(ttl * 2)
expect(listPreparations()).toEqual([entry])
releaseSecond()
await vi.advanceTimersByTimeAsync(ttl - 1)
expect(mocks.discard).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(listPreparations()).toEqual([])
expect(mocks.discard).toHaveBeenCalledOnce()
})
it('never deletes a held checkout after Create has claimed it', async () => {
await startPreparation(args)
const entry = listPreparations()[0]
const release = holdPreparation(entry)
takePreparation(entry)
release()
await vi.advanceTimersByTimeAsync(ttl * 2)
expect(mocks.discard).not.toHaveBeenCalled()
})
it('preserves the pool limit even if every entry is held', async () => {
const releases: (() => void)[] = []
for (let i = 0; i <= limit; i++) {
await startPreparation({ ...args, canonicalBase: `refs/heads/branch-${i}` })
releases.push(holdPreparation(listPreparations().at(-1)!))
}
expect(listPreparations()).toHaveLength(limit)
await vi.advanceTimersByTimeAsync(0)
expect(mocks.discard).toHaveBeenCalledOnce()
releases.forEach((release) => release())
})
+1
View File
@@ -69,6 +69,7 @@ export type WorktreeApi = {
onCreateProgress: (
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
) => () => void
setCreateStandby?: (args: { repoId: string | null; baseBranch?: string }) => Promise<void>
prepareCreateCheckout?: (args: { repoId: string; baseBranch?: string }) => Promise<void>
prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise<void>
resolvePrBase: (args: {
+2
View File
@@ -41,6 +41,8 @@ export const worktreesApi = {
return () => ipcRenderer.removeListener('createWorktree:progress', listener)
},
setCreateStandby: (args) => ipcRenderer.invoke('worktrees:setCreateStandby', args),
prepareCreateCheckout: (args) => ipcRenderer.invoke('worktrees:prepareCreateCheckout', args),
prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args),
@@ -6,6 +6,7 @@ import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGa
import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
import { useAppStore } from '../store'
import { WorktreeCreateStandbyGate } from './WorktreeCreateStandbyGate'
import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge'
const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge'))
@@ -23,6 +24,7 @@ export function AppBackgroundServices(): React.JSX.Element {
return (
<>
<WorkspacePortScanner enabled={workspaceSessionReady} />
<WorktreeCreateStandbyGate enabled={workspaceSessionReady} />
{/* Why: plugin language-pack discovery must not re-render the App shell. */}
<MacosTccPromptNoticeHost />
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
@@ -0,0 +1,239 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { getDefaultSettings } from '../../../shared/constants'
import type { AppState } from '../store/types'
import type { Repo } from '../../../shared/repo-types'
const mocks = vi.hoisted(() => ({ web: false, setStandby: vi.fn() }))
vi.mock('../store', () => ({ useAppStore: create<AppState>(() => ({}) as AppState) }))
vi.mock('../lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
import { useAppStore } from '../store'
import { WorktreeCreateStandbyGate } from './WorktreeCreateStandbyGate'
import { resetInputQuietSchedulerForTest } from '../lib/input-quiet-scheduler'
const repo = { id: 'one', path: '/one' } as Repo
const second = { id: 'two', path: '/two' } as Repo
function visible(value: boolean): void {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: value ? 'visible' : 'hidden'
})
act(() => document.dispatchEvent(new Event('visibilitychange')))
}
async function settle(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(3_100)
})
}
beforeEach(() => {
vi.useFakeTimers()
resetInputQuietSchedulerForTest()
mocks.web = false
mocks.setStandby.mockReset().mockResolvedValue(undefined)
Object.defineProperty(window, 'api', {
configurable: true,
value: { worktrees: { setCreateStandby: mocks.setStandby } }
})
visible(true)
useAppStore.setState({
repos: [repo, second],
projects: [],
activeRepoId: repo.id,
settings: null,
newWorkspaceDraft: null,
workspaceHostScope: 'all'
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it('prepares only the latest target after rapid switches', async () => {
render(<WorktreeCreateStandbyGate enabled />)
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
act(() => useAppStore.setState({ activeRepoId: second.id }))
await settle()
expect(mocks.setStandby.mock.calls).toEqual([[{ repoId: second.id }]])
})
it('cancels pending work while hidden and releases a prepared target on unmount', async () => {
const view = render(<WorktreeCreateStandbyGate enabled />)
visible(false)
await settle()
expect(mocks.setStandby).not.toHaveBeenCalled()
visible(true)
await settle()
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: repo.id })
view.unmount()
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
})
it('releases when hidden and does not refill from unrelated state updates', async () => {
render(<WorktreeCreateStandbyGate enabled />)
await settle()
act(() => useAppStore.setState({ repos: [{ ...repo }, { ...second }] }))
await settle()
expect(mocks.setStandby).toHaveBeenCalledTimes(1)
visible(false)
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
})
it.each([
{ ...repo, connectionId: 'ssh' },
{ ...repo, kind: 'folder' },
{ ...repo, executionHostId: 'runtime:remote' },
{ ...repo, executionHostId: 'ssh:remote' }
])('does not prepare an ineligible owner or folder', async (target) => {
useAppStore.setState({ repos: [target as Repo] })
render(<WorktreeCreateStandbyGate enabled />)
await settle()
expect(mocks.setStandby).not.toHaveBeenCalled()
})
it('honors explicit local ownership while another runtime is focused', async () => {
useAppStore.setState({
repos: [{ ...repo, executionHostId: 'local' }],
settings: { activeRuntimeEnvironmentId: 'remote' } as AppState['settings']
})
render(<WorktreeCreateStandbyGate enabled />)
await settle()
expect(mocks.setStandby).toHaveBeenCalledWith({ repoId: repo.id })
})
it('does not route an unowned legacy repo through a focused runtime', async () => {
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: 'remote' } as AppState['settings']
})
render(<WorktreeCreateStandbyGate enabled />)
await settle()
expect(mocks.setStandby).not.toHaveBeenCalled()
})
it('matches the persisted composer draft repo and base', async () => {
useAppStore.setState({
newWorkspaceDraft: { repoId: second.id, baseBranch: 'release' } as AppState['newWorkspaceDraft']
})
render(<WorktreeCreateStandbyGate enabled />)
await settle()
expect(mocks.setStandby).toHaveBeenCalledWith({ repoId: second.id, baseBranch: 'release' })
})
it.each(['web', 'missing', 'disabled'])('does nothing when unavailable: %s', async (reason) => {
mocks.web = reason === 'web'
if (reason === 'missing') {
delete window.api.worktrees.setCreateStandby
}
render(<WorktreeCreateStandbyGate enabled={reason !== 'disabled'} />)
await settle()
expect(mocks.setStandby).not.toHaveBeenCalled()
})
it('waits for input quiet before preparing', async () => {
render(<WorktreeCreateStandbyGate enabled />)
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500)
})
act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })))
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(mocks.setStandby).not.toHaveBeenCalled()
await settle()
expect(mocks.setStandby).toHaveBeenCalledExactlyOnceWith({ repoId: repo.id })
})
it('releases the old target before preparing the next', async () => {
render(<WorktreeCreateStandbyGate enabled />)
await settle()
act(() => useAppStore.setState({ activeRepoId: second.id }))
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
await settle()
expect(mocks.setStandby.mock.calls).toEqual([
[{ repoId: repo.id }],
[{ repoId: null }],
[{ repoId: second.id }]
])
})
it('cancels a scheduled preparation on unmount', async () => {
const view = render(<WorktreeCreateStandbyGate enabled />)
view.unmount()
await settle()
expect(mocks.setStandby).not.toHaveBeenCalled()
})
it.each(['workspaceDir', 'nestWorkspaces', 'localWindowsRuntimeDefault'] as const)(
'releases and re-arms when placement/runtime setting changes: %s',
async (key) => {
const settings = getDefaultSettings('/tmp')
useAppStore.setState({ settings })
render(<WorktreeCreateStandbyGate enabled />)
await settle()
act(() =>
useAppStore.setState({
settings: {
...settings,
[key]:
key === 'workspaceDir'
? '/new-root'
: key === 'nestWorkspaces'
? !settings.nestWorkspaces
: { kind: 'wsl', distro: 'Ubuntu' }
} as AppState['settings']
})
)
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
await settle()
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: repo.id })
}
)
it('re-arms for the selected project runtime and repo placement, ignoring another project', async () => {
const project = { id: 'project', sourceRepoIds: [repo.id] } as AppState['projects'][number]
useAppStore.setState({ projects: [project] })
render(<WorktreeCreateStandbyGate enabled />)
await settle()
act(() =>
useAppStore.setState({
projects: [
project,
{
id: 'other',
sourceRepoIds: [second.id],
localWindowsRuntimePreference: { kind: 'windows-host' }
} as AppState['projects'][number]
]
})
)
await settle()
expect(mocks.setStandby).toHaveBeenCalledTimes(1)
act(() =>
useAppStore.setState({
projects: [{ ...project, localWindowsRuntimePreference: { kind: 'windows-host' } }]
})
)
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
await settle()
act(() => useAppStore.setState({ repos: [{ ...repo, worktreeBasePath: '/new-root' }] }))
expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null })
await settle()
expect(mocks.setStandby).toHaveBeenCalledTimes(5)
})
it('ignores unrelated settings and terminal state churn', async () => {
render(<WorktreeCreateStandbyGate enabled />)
await settle()
act(() =>
useAppStore.setState({
settings: { theme: 'dark' } as AppState['settings'],
activeWorktreeId: 'other-workspace'
})
)
await settle()
expect(mocks.setStandby).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,122 @@
import { useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../store'
import type { AppState } from '../store/types'
import {
getComposerEligibleRepos,
resolveComposerActiveRepoId,
resolveComposerGitRepoId
} from '../lib/new-workspace-composer-repo'
import { settingsForKnownRepoOwner } from '../store/slices/worktrees/listing/worktree-owner-settings'
import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client'
import { isWebClientLocation } from '../lib/web-client-location'
import { scheduleAfterInputQuiet } from '../lib/input-quiet-scheduler'
import { isWindowVisible } from '../lib/window-visibility-interval'
import { getRepoExecutionHostId } from '../../../shared/execution-host'
type StandbyState = Pick<AppState, 'repos' | 'projects' | 'activeRepoId' | 'workspaceHostScope'> & {
draftRepoId: string | null | undefined
draftBaseBranch: string | undefined
activeRuntimeEnvironmentId: string | null | undefined
workspaceDir: string | undefined
nestWorkspaces: boolean | undefined
localWindowsRuntimeDefault:
| NonNullable<AppState['settings']>['localWindowsRuntimeDefault']
| undefined
}
function standbyIdentity(state: StandbyState): string | null {
const eligibleRepos = getComposerEligibleRepos(state.repos)
const repoId = resolveComposerGitRepoId({
eligibleRepos,
activeRepoId: resolveComposerActiveRepoId(state.repos, eligibleRepos, state.activeRepoId),
draftRepoId: state.draftRepoId,
focusedHostScope: state.workspaceHostScope
})
const repo = eligibleRepos.find((candidate) => candidate.id === repoId)
if (
!repo ||
repo.connectionId ||
getRepoExecutionHostId(repo) !== 'local' ||
getActiveRuntimeTarget(
settingsForKnownRepoOwner(
{ activeRuntimeEnvironmentId: state.activeRuntimeEnvironmentId } as AppState['settings'],
repo
)
).kind !== 'local'
) {
return null
}
const project = state.projects.find((entry) => entry.sourceRepoIds.includes(repo.id))
return JSON.stringify({
repoId: repo.id,
path: repo.path,
worktreeBaseRef: repo.worktreeBaseRef,
worktreeBasePath: repo.worktreeBasePath,
workspaceDir: state.workspaceDir,
nestWorkspaces: state.nestWorkspaces,
projectId: project?.id,
projectRuntime: project?.localWindowsRuntimePreference,
localWindowsRuntimeDefault: state.localWindowsRuntimeDefault,
baseBranch: state.draftRepoId === repo.id ? state.draftBaseBranch : undefined
})
}
export function WorktreeCreateStandbyGate({ enabled }: { enabled: boolean }): null {
// After Create, the pool's burst policy owns replenishment; unrelated workspace changes stay quiet.
const inputs = useAppStore(
useShallow((state): StandbyState => ({
repos: state.repos,
projects: state.projects,
activeRepoId: state.activeRepoId,
workspaceHostScope: state.workspaceHostScope,
draftRepoId: state.newWorkspaceDraft?.repoId,
draftBaseBranch: state.newWorkspaceDraft?.baseBranch,
activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId,
workspaceDir: state.settings?.workspaceDir,
nestWorkspaces: state.settings?.nestWorkspaces,
localWindowsRuntimeDefault: state.settings?.localWindowsRuntimeDefault
}))
)
const identity = useMemo(() => standbyIdentity(inputs), [inputs])
useEffect(() => {
const setStandby = window.api?.worktrees?.setCreateStandby
if (!enabled || !identity || isWebClientLocation() || !setStandby) {
return
}
const { repoId, baseBranch } = JSON.parse(identity) as {
repoId: string
baseBranch?: string
}
let cancelScheduled: (() => void) | undefined
let requested = false
const release = (): void => {
cancelScheduled?.()
cancelScheduled = undefined
if (requested) {
requested = false
void setStandby({ repoId: null }).catch(() => {})
}
}
const reconcile = (): void => {
release()
if (!isWindowVisible()) {
return
}
cancelScheduled = scheduleAfterInputQuiet(
() => {
requested = true
void setStandby({ repoId, ...(baseBranch ? { baseBranch } : {}) }).catch(() => {})
},
{ delayMs: 2_000, quietMs: 2_000, idleTimeoutMs: 1_000 }
)
}
reconcile()
document.addEventListener('visibilitychange', reconcile)
return () => {
document.removeEventListener('visibilitychange', reconcile)
release()
}
}, [enabled, identity])
return null
}