Files
orca/src/main/worktree-create-preparation.ts
T
Brennan Benson d6d2795da5 chore(worktree): include create timing and spare outcome in the workspace create events (#24483)
* chore(worktree): include create timing and spare outcome in the workspace-created event

The workspace_created and workspace_create_failed events gain optional,
numbers-and-enums-only fields built from what the create already measured:
total and per-phase durations, the prepared-checkout hit/miss and miss
reason, the execution host (local/WSL/SSH), a worktree count bucket, how
many other creates were in flight, whether the repo has a post-checkout
hook (file existence only, probed after the create returns), and for a
failure the phase it died in plus elapsed time. No new git process runs;
consent and opt-out are unchanged.

* fix(worktree): attribute failed_phase by error, label WSL-path repos, skip the hook check with telemetry off

- failed_phase now names the outermost timed step the thrown error (or its cause) left, so a
  caught failure or a concurrent sibling step can no longer be misattributed; the old-relay SSH
  error keeps its cause so it still reads as git_worktree_add.
- execution_host follows the same rule Git routing uses, so a \\wsl.localhost repo reads wsl.
- The post-checkout hook check does not read the repo when telemetry is disabled.
- Privacy page mentions the miss reason code and the failed step.

* test(worktree): pin the old-relay SSH add error to git_worktree_add through its cause

* fix(worktree): name the create event field sets for their role, and type the old-relay test's caught error

* fix(worktree): send create events from runtime creates and record what the spare checkout did

Runtime creates (CLI, agents, phone app, paired clients, orchestration, server
automations) reuse prepared checkouts like the app's own creates, but recorded
no timing and sent no events. Both entry points now start one shared sender
(workspace-create-telemetry.ts), so every create sends exactly one event with
the same fields, plus create_entry_point (app | runtime).

Spare-checkout fields:
- concurrent_preparations: peak prepared-checkout builds and background
  discards running during the create, excluding the one it used; the window
  closes before the create's own re-arm starts.
- prepared_checkout_claim / prepared_checkout_discard phases, so on a miss
  git_worktree_add minus the prepared_checkout_* phases is the plain checkout.
- prepared_checkout_reset (none | base_moved | retargeted) replaces the
  retargeted flag; prepared_checkout_origin (prefetch | rearm) on hits.
- workspace_create_failed carries the spare outcome and its wait.
- repo_index_size_bucket from one stat of .git/index in the existing
  post-create probe (telemetry on, local/WSL only, 2 s cap).

* fix(worktree): add spare build and idle time, the re-arm prefetch origin, and a tracked-file count

- prepared_checkout_build_ms / prepared_checkout_idle_ms on hits: from arming
  the spare to ready, and how long it sat ready before the claim (0 when the
  create waited). readyAt is recorded in the pool's existing ready handler.
- prepared_checkout_origin gains rearm_then_prefetch: an automatic re-arm that
  the dialog prefetch then asked for too, so rearm means the re-arm alone.
- repo_file_count_bucket replaces the index byte size: the entry count from
  the 12-byte index header, which is the same in every index version; left
  out for a split or sparse index.
- The shared sender never lets a failed send change the create's result or
  error; it logs instead and still ends the create's concurrency membership.
- Tests pin the runtime SSH create's timing hand-off and the throwing-send
  cases on both entry points.

* fix(worktree): leave out the file count under any sparse checkout and time spare builds monotonically

- The repo probe also reads .git/config.worktree, where git sparse-checkout
  --sparse-index writes index.sparse, and omits the file count whenever
  sparse checkout or a sparse index is on in either file, with Git's boolean
  spellings. core.hooksPath there is honoured too.
- prepared_checkout_build_ms / _idle_ms use performance.now(), like every
  other duration; the build is timed from its own start (buildStartedAt).
- The origin field comment names all three values.

* fix(worktree): keep the spare's build time on its first build and count worktrees by lock reason

- prepared_checkout_build_ms runs from the first build's start (including any
  wait for the base fetch it is built on) to its first ready; a later tip
  refresh no longer restarts it, though it still counts as new preparation
  work. prepared_checkout_idle_ms runs from the latest ready (build or
  refresh) to the claim.
- The worktree count reads each .git/worktrees entry's locked file and leaves
  out entries whose lock reason names an Orca preparation, the way the
  listing does, instead of subtracting this process's spares. That covers
  spares from other processes, crash leftovers and spares being discarded,
  and cannot run one low while a spare's admin dir does not exist yet. It has
  its own 1.5 s cap inside the probe.
2026-10-02 12:46:13 -07:00

311 lines
11 KiB
TypeScript

import { worktreePreparationGit } from './git/worktree-create-git-executor'
import { mkdir } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { isFolderRepo } from '../shared/repo-kind'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import type {
PreparedCheckoutMissReason,
PreparedCheckoutOrigin,
PreparedCheckoutReset
} from '../shared/worktree/create-types'
import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary'
import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree'
import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock'
import {
_resetPreparationPoolForTests,
hasPendingPreparations,
releasePreparationClaim,
startPreparation,
type DeferredPreparation,
type PreparationClaim,
type PreparationEntry
} from './worktree-create-preparation-pool'
import {
canonicalBaseRef,
reservePreparation,
type ReservedPreparation
} from './worktree-create-preparation-reservation'
import {
discardPreparedWorktree,
finalizePreparedWorktree
} from './git/worktree-create-preparation'
import {
getLocalProjectWorktreeGitOptions,
getWorktreeMirrorDistro
} from './project-runtime-git-options'
import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic'
import {
recordPreparationConsume,
resetPreparationConsumeHistoryForTests
} from './worktree-create-preparation-burst'
import { toHostFilesystemPath } from './host-tree-removal'
import type { WorktreeCreateTimingRecorder } from './worktree-create-timing'
export {
WORKTREE_CREATE_PREPARATION_LIMIT,
WORKTREE_CREATE_PREPARATION_TTL_MS
} from './worktree-create-preparation-pool'
/** A prepared checkout is a create that is either in flight or imminent. */
export function hasPendingWorktreeCreatePreparations(): boolean {
return hasPendingPreparations()
}
/** Carries the consumed slot's pending re-arm to the create's outermost `finally`, which fires it
* once — after startup on success, and on any failure that follows the consume. */
export type PreparationRearmHolder = { fire: () => void }
export type PreparedWorktreeCreateAttempt =
| {
status: 'hit'
retargeted: boolean
reset: PreparedCheckoutReset
origin: PreparedCheckoutOrigin
buildMs: number
idleMs: number
result: AddWorktreeResult
/** Run after materialization/startup completes, before returning the create result. */
rearm: () => void
}
| { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void }
type ConsumePreparedWorktreeArgs = {
repoPath: string
workspaceRoot: string
worktreePath: string
branch: string
baseBranch: string
refreshLocalBaseRef?: boolean
options?: AddWorktreeOptions
timing?: Pick<
WorktreeCreateTimingRecorder,
'time' | 'recordPreparedCheckout' | 'recordAdoptedPreparation'
>
}
function timePhase<T>(
args: ConsumePreparedWorktreeArgs,
phase: WorktreeCreatePhase,
operation: () => Promise<T>
): Promise<T> {
return args.timing ? args.timing.time(phase, operation) : operation()
}
export function prepareWorktreeCreateForRepo(
store: Store,
repo: Repo,
baseBranch: string,
beforeMaterialization?: Promise<void>
): Promise<void> {
return worktreePreparationGit.run(() =>
prepareWorktreeCreateInBackground(store, repo, baseBranch, beforeMaterialization)
)
}
async function prepareWorktreeCreateInBackground(
store: Store,
repo: Repo,
baseBranch: string,
beforeMaterialization?: Promise<void>
): Promise<void> {
if (repo.connectionId || isFolderRepo(repo)) {
return
}
const options = getLocalProjectWorktreeGitOptions(store, repo)
// Resolving a WSL repo's root spawns `wsl.exe`, and this runs while the create composer is open,
// so it must not block the main thread. Key lookup and insert stay in one sync run after the await.
// The mirror distro must be threaded exactly as createLocalWorktree threads it, or the two sides
// key on different roots and every prepared checkout is discarded.
const workspaceRoot = await computeWorkspaceRootAsync(
repo.path,
getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo))
)
const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options)
return startPreparation({
repoPath: repo.path,
workspaceRoot,
baseBranch,
canonicalBase,
options,
beforeMaterialization
})
}
type ClaimedPreparation =
| ({ status: 'claimed'; claimedAt: number } & ReservedPreparation)
| { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void }
async function claimPreparedWorktree(
args: ConsumePreparedWorktreeArgs,
options: AddWorktreeOptions
): Promise<ClaimedPreparation> {
// Timed on its own so a miss's probes and drift check are not read as the plain add's cost.
const reserved = await timePhase(args, 'prepared_checkout_claim', () =>
reservePreparation(args, options)
)
if (reserved.status === 'miss') {
return reserved
}
const { entry, reservation } = reserved
args.timing?.recordAdoptedPreparation(entry.activity.work)
const claimedAt = performance.now()
try {
await timePhase(args, 'prepared_checkout_wait', () => entry.ready)
return { ...reserved, status: 'claimed', claimedAt }
} catch {
return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) }
}
}
function startDeferredPreparation(preparation: DeferredPreparation): void {
void startPreparation(preparation.args, preparation.kind).catch(() => {
// A later create still has the normal add path if speculative preparation fails.
})
}
function releaseClaimAfterCreate(reservation: PreparationClaim): () => void {
return () => {
for (const preparation of releasePreparationClaim(reservation).pendingPreparations) {
startDeferredPreparation(preparation)
}
}
}
/** Replaces a just-consumed preparation, re-armed on the base the create actually used so the
* next one hits exactly — but only once the user has shown they are creating in a burst. A
* replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one after an
* isolated create spends that on nobody.
*
* Returns a thunk rather than launching: the replacement is a full `reset --hard`, which on a
* large repo holds a general admission slot for tens of seconds. Started mid-create it competes
* with the create's own git, so the caller runs it after materialization/startup completes. The burst
* bookkeeping still happens here so a later create is recognized as part of a burst. An explicit
* prefetch during this create takes precedence over the burst replacement at release. */
function deferRearmPreparation(
entry: PreparationEntry,
reservation: PreparationClaim,
baseBranch: string,
canonicalBase: string
): () => void {
const continuesBurst = recordPreparationConsume(entry.key)
return () => {
const { released, pendingPreparations } = releasePreparationClaim(reservation)
if (!released) {
return
}
const requestedBaseArmed = pendingPreparations.some(
(preparation) => preparation.args.canonicalBase === canonicalBase
)
for (const preparation of pendingPreparations) {
startDeferredPreparation(preparation)
}
if (continuesBurst && !requestedBaseArmed) {
startDeferredPreparation({
args: {
repoPath: entry.repoPath,
workspaceRoot: entry.workspaceRoot,
baseBranch,
canonicalBase,
options: entry.options
},
kind: 'automatic'
})
}
}
}
export async function consumePreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
): Promise<PreparedWorktreeCreateAttempt> {
const attempt = await attemptPreparedWorktreeCreate(args)
args.timing?.recordPreparedCheckout(
attempt.status === 'hit'
? {
status: 'hit',
reset: attempt.reset,
origin: attempt.origin,
buildMs: attempt.buildMs,
idleMs: attempt.idleMs
}
: { status: 'miss', reason: attempt.reason }
)
return attempt
}
function preparedCheckoutReset(retargeted: boolean, headReset: boolean): PreparedCheckoutReset {
if (!headReset) {
return 'none'
}
return retargeted ? 'retargeted' : 'base_moved'
}
async function attemptPreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
): Promise<PreparedWorktreeCreateAttempt> {
const options = args.options ?? {}
const claim = await claimPreparedWorktree(args, options)
if (claim.status === 'miss') {
return { status: 'miss', reason: claim.reason, ...(claim.rearm ? { rearm: claim.rearm } : {}) }
}
const { entry, reservation } = claim
try {
// Finalize resolves the requested base itself and resets the prepared checkout onto that
// commit, so a retargeted claim is handed over at the requested commit or not at all.
const { preparedHeadReset, ...result } = await timePhase(
args,
'prepared_checkout_finalize',
async () => {
const parentDir = isWindowsAbsolutePathLike(args.worktreePath)
? win32.dirname(args.worktreePath)
: posix.dirname(args.worktreePath)
await mkdir(toHostFilesystemPath(parentDir), { recursive: true })
return finalizePreparedWorktree(
args.repoPath,
entry.preparedPath,
args.worktreePath,
args.branch,
args.baseBranch,
args.refreshLocalBaseRef,
options,
entry.lockReason
)
}
)
// Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is
// creating in a burst; the TTL and the preparation limit still bound an unused replacement.
const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase)
return {
status: 'hit',
retargeted: claim.retargeted,
reset: preparedCheckoutReset(claim.retargeted, preparedHeadReset),
origin: entry.activity.origin(),
...entry.activity.timesAt(claim.claimedAt),
result,
rearm
}
} catch (error) {
// Another owner holds the preparation's lock, so it is not ours to remove.
if (!(error instanceof WorktreePreparationLockOwnershipError)) {
await timePhase(args, 'prepared_checkout_discard', () =>
discardPreparedWorktree(args.repoPath, entry.preparedPath, options, entry.lockReason)
).catch(() => {})
}
console.warn(
'[worktree-create] prepared checkout could not be finalized; using normal add',
error
)
return {
status: 'miss',
reason: 'finalize_failed',
rearm: releaseClaimAfterCreate(reservation)
}
}
}
export async function _resetWorktreeCreatePreparationsForTests(): Promise<void> {
resetPreparationConsumeHistoryForTests()
await _resetPreparationPoolForTests()
}