mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
perf(worktree): retain an idle preparation for the active composer target
This commit is contained in:
@@ -27,6 +27,7 @@ const WORKTREE_HANDLER_CHANNELS = [
|
||||
'worktrees:adoptProvisionedRoot',
|
||||
'worktrees:prefetchCreateBase',
|
||||
'worktrees:prepareCreateCheckout',
|
||||
'worktrees:setCreateStandby',
|
||||
'worktrees:resolvePrBase',
|
||||
'worktrees:resolveMrBase',
|
||||
'worktrees:remove',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user