Improve new worktree startup timing
This commit is contained in:
parent
c9c0418612
commit
3079e4690a
|
|
@ -1442,6 +1442,19 @@ export function registerPtyHandlers(
|
|||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
if (args.telemetry) {
|
||||
const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind)
|
||||
const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source)
|
||||
const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind)
|
||||
if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) {
|
||||
track('agent_started', {
|
||||
agent_kind: agentKindParse.data,
|
||||
launch_source: launchSourceParse.data,
|
||||
request_kind: requestKindParse.data,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
}
|
||||
}
|
||||
// Why: runtime-owned CLI PTYs bypass the renderer `pty:spawn` handler,
|
||||
// so record their spawn-time paneKey here too. Synthetic hook titles and
|
||||
// paneKey-scoped cache cleanup both depend on this reverse lookup.
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import type {
|
|||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
GitPushTarget,
|
||||
GlobalSettings,
|
||||
LocalBaseRefRefreshResult,
|
||||
Repo,
|
||||
Worktree,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import { getPRForBranch } from '../github/client'
|
||||
|
|
@ -46,7 +48,7 @@ import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
|||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import {
|
||||
|
|
@ -70,6 +72,13 @@ import { createWorktreeSymlinks } from './worktree-symlinks'
|
|||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||||
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
markCopilotFolderTrusted,
|
||||
markCursorWorkspaceTrusted
|
||||
} from '../agent-trust-presets'
|
||||
|
||||
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
|
||||
const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512
|
||||
|
|
@ -86,6 +95,114 @@ type RemoteWorktreeCreateBasePlan = {
|
|||
remoteTrackingBase: RemoteTrackingBase | null
|
||||
}
|
||||
|
||||
type StagedStartupResult = {
|
||||
startupTerminal?: CreateWorktreeResult['startupTerminal']
|
||||
didSpawnSetup: boolean
|
||||
warning?: string
|
||||
}
|
||||
|
||||
function appendWorktreeCreateWarning(current: string | undefined, next: string): string {
|
||||
return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next
|
||||
}
|
||||
|
||||
async function spawnLocalStartupAndSetupTerminals(args: {
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
worktree: Pick<Worktree, 'id' | 'path'>
|
||||
startup: CreateWorktreeArgs['startup']
|
||||
setup: CreateWorktreeResult['setup']
|
||||
defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
settings: GlobalSettings
|
||||
createdWithAgent: CreateWorktreeArgs['createdWithAgent']
|
||||
}): Promise<StagedStartupResult> {
|
||||
const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args
|
||||
if (!runtime || !startup || defaultTabs?.tabs.length) {
|
||||
return { didSpawnSetup: false }
|
||||
}
|
||||
|
||||
let warning: string | undefined
|
||||
let startupTerminalHandle: string | null = null
|
||||
let startupTerminal: CreateWorktreeResult['startupTerminal']
|
||||
|
||||
try {
|
||||
// Why: after `git worktree add` and metadata registration, a runtime-owned
|
||||
// PTY can begin booting the selected agent while setup runs in a sibling
|
||||
// terminal. Earlier than this, the worktree path is not yet safe for agents.
|
||||
if (isTuiAgent(createdWithAgent)) {
|
||||
const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust
|
||||
try {
|
||||
if (preset === 'cursor') {
|
||||
markCursorWorkspaceTrusted(worktree.path)
|
||||
} else if (preset === 'copilot') {
|
||||
markCopilotFolderTrusted(worktree.path)
|
||||
} else if (preset === 'codex') {
|
||||
markCodexProjectTrusted(worktree.path)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: launch still proceeds and the agent can ask interactively.
|
||||
}
|
||||
}
|
||||
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
|
||||
command: startup.command,
|
||||
env: startup.env,
|
||||
telemetry: startup.telemetry,
|
||||
activate: true
|
||||
})
|
||||
startupTerminalHandle = terminal.handle
|
||||
startupTerminal = {
|
||||
spawned: true,
|
||||
surface: terminal.surface
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
warning = `Failed to create the startup terminal for ${worktree.path}: ${message}`
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
return { didSpawnSetup: false, warning }
|
||||
}
|
||||
|
||||
let didSpawnSetup = false
|
||||
if (setup) {
|
||||
try {
|
||||
const setupCommand = buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const setupLaunchMode =
|
||||
(settings as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
|
||||
.setupScriptLaunchMode ?? 'new-tab'
|
||||
if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
|
||||
if (!startupTerminalHandle) {
|
||||
throw new Error('startup_terminal_missing')
|
||||
}
|
||||
await runtime.splitTerminal(startupTerminalHandle, {
|
||||
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
|
||||
command: setupCommand,
|
||||
env: setup.envVars,
|
||||
activate: false
|
||||
})
|
||||
} else {
|
||||
await runtime.createTerminal(`id:${worktree.id}`, {
|
||||
title: 'Setup',
|
||||
command: setupCommand,
|
||||
env: setup.envVars,
|
||||
activate: false
|
||||
})
|
||||
}
|
||||
didSpawnSetup = true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const nextWarning = `failed to create the setup terminal for ${worktree.path}: ${message}`
|
||||
warning = appendWorktreeCreateWarning(warning, nextWarning)
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(startupTerminal ? { startupTerminal } : {}),
|
||||
didSpawnSetup,
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function setBoundedSshWorktreeCreateFetchEntry(
|
||||
map: Map<string, number>,
|
||||
key: string,
|
||||
|
|
@ -1099,6 +1216,7 @@ export async function createRemoteWorktree(
|
|||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): Promise<CreateWorktreeResult> {
|
||||
const timing = createWorktreeCreateTimingRecorder()
|
||||
const provider = requireSshGitProvider(repo.connectionId!)
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
|
||||
|
||||
|
|
@ -1280,13 +1398,15 @@ export async function createRemoteWorktree(
|
|||
|
||||
// Create worktree via relay
|
||||
try {
|
||||
await provider.addWorktree(
|
||||
repo.path,
|
||||
branchName,
|
||||
remotePath,
|
||||
checkoutExistingBranch
|
||||
? { checkoutExistingBranch }
|
||||
: { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) }
|
||||
await timing.time('git_worktree_add', async () =>
|
||||
provider.addWorktree(
|
||||
repo.path,
|
||||
branchName,
|
||||
remotePath,
|
||||
checkoutExistingBranch
|
||||
? { checkoutExistingBranch }
|
||||
: { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) }
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
if (
|
||||
|
|
@ -1330,7 +1450,9 @@ export async function createRemoteWorktree(
|
|||
}
|
||||
|
||||
// Re-list to get the created worktree info
|
||||
const gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
const gitWorktrees = await timing.time('list_created_worktree', async () =>
|
||||
provider.listWorktrees(repo.path)
|
||||
)
|
||||
const created = gitWorktrees.find(
|
||||
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName)
|
||||
)
|
||||
|
|
@ -1392,8 +1514,10 @@ export async function createRemoteWorktree(
|
|||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
const worktree = mergeWorktree(repo.id, created, meta)
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
return { worktree: mergeWorktree(repo.id, created, meta) }
|
||||
})
|
||||
|
||||
// Why: `experimentalWorktreeSymlinks` is intentionally not wired up for
|
||||
// remote (SSH) worktrees. Creating symlinks on the remote host would
|
||||
|
|
@ -1404,43 +1528,45 @@ export async function createRemoteWorktree(
|
|||
let setup: CreateWorktreeResult['setup']
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
if (fsProvider) {
|
||||
const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path)
|
||||
const hooks = getEffectiveHooksFromConfig(repo, yamlHooks)
|
||||
try {
|
||||
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error)
|
||||
defaultTabs = yamlHooks?.defaultTabs
|
||||
? { tabs: yamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
const setupScript = hooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
await timing.time('prepare_setup', async () => {
|
||||
const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path)
|
||||
const hooks = getEffectiveHooksFromConfig(repo, yamlHooks)
|
||||
try {
|
||||
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: the remote worktree already exists. If the created branch adds
|
||||
// a setup hook without a renderer decision, skip setup instead of
|
||||
// reporting successful git creation as failed.
|
||||
console.warn(`[hooks] setup hook skipped for ${created.path}:`, error)
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error)
|
||||
defaultTabs = yamlHooks?.defaultTabs
|
||||
? { tabs: yamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
if (setupScript && shouldLaunchSetup) {
|
||||
try {
|
||||
setup = await createRemoteSetupRunnerScript(
|
||||
repo,
|
||||
created.path,
|
||||
setupScript,
|
||||
provider,
|
||||
fsProvider
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
|
||||
const setupScript = hooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
try {
|
||||
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: the remote worktree already exists. If the created branch adds
|
||||
// a setup hook without a renderer decision, skip setup instead of
|
||||
// reporting successful git creation as failed.
|
||||
console.warn(`[hooks] setup hook skipped for ${created.path}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (setupScript && shouldLaunchSetup) {
|
||||
try {
|
||||
setup = await createRemoteSetupRunnerScript(
|
||||
repo,
|
||||
created.path,
|
||||
setupScript,
|
||||
provider,
|
||||
fsProvider
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
|
|
@ -1448,7 +1574,8 @@ export async function createRemoteWorktree(
|
|||
worktree,
|
||||
...(setup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(localBaseRefRefresh ? { localBaseRefRefresh } : {})
|
||||
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
|
||||
timing: timing.finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1459,6 +1586,7 @@ export async function createLocalWorktree(
|
|||
mainWindow: BrowserWindow,
|
||||
runtime?: OrcaRuntimeService
|
||||
): Promise<CreateWorktreeResult> {
|
||||
const timing = createWorktreeCreateTimingRecorder()
|
||||
const settings = store.getSettings()
|
||||
const worktreePathSettings = getWorktreePathSettings(repo, settings)
|
||||
|
||||
|
|
@ -1685,22 +1813,26 @@ export async function createLocalWorktree(
|
|||
}
|
||||
|
||||
if (remoteTrackingRefresh) {
|
||||
const result = await remoteTrackingRefresh.promise
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!remoteTrackingRefresh.hadLocalBaseRef &&
|
||||
!(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base))
|
||||
) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
await timing.time('refresh_base_ref', async () => {
|
||||
const result = await remoteTrackingRefresh.promise
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!remoteTrackingRefresh.hadLocalBaseRef &&
|
||||
!(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base))
|
||||
) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (legacyFetchPromise) {
|
||||
await legacyFetchPromise
|
||||
await timing.time('refresh_base_ref', async () => {
|
||||
await legacyFetchPromise
|
||||
})
|
||||
}
|
||||
emitCreateWorktreeProgress(mainWindow, 'creating')
|
||||
|
||||
|
|
@ -1714,42 +1846,44 @@ export async function createLocalWorktree(
|
|||
|
||||
const existingBranchOption = { checkoutExistingBranch }
|
||||
const addResult: AddWorktreeResult =
|
||||
(await (sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))) ?? {}
|
||||
(await timing.time('git_worktree_add', async () =>
|
||||
sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
existingBranchOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
existingBranchOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
)) ?? {}
|
||||
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
|
|
@ -1765,7 +1899,9 @@ export async function createLocalWorktree(
|
|||
}
|
||||
|
||||
// Re-list to get the freshly created worktree info
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
const gitWorktrees = await timing.time('list_created_worktree', async () =>
|
||||
listWorktrees(repo.path)
|
||||
)
|
||||
const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath))
|
||||
if (!created) {
|
||||
throw new Error('Worktree created but not found in listing')
|
||||
|
|
@ -1815,8 +1951,10 @@ export async function createLocalWorktree(
|
|||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||||
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
|
||||
}
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
const worktree = mergeWorktree(repo.id, created, meta)
|
||||
const { worktree } = timing.timeSync('persist_metadata', () => {
|
||||
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
||||
return { worktree: mergeWorktree(repo.id, created, meta) }
|
||||
})
|
||||
// Why: the authorized-roots cache is consulted lazily on the next filesystem
|
||||
// access (`ensureAuthorizedRootsCache` rebuilds on demand when dirty). We
|
||||
// just invalidate the cache marker instead of blocking worktree creation on
|
||||
|
|
@ -1829,8 +1967,11 @@ export async function createLocalWorktree(
|
|||
// state (e.g. `node_modules`, `.env`) see the links already in place.
|
||||
// Gated on the experimental flag so disabling the feature globally skips
|
||||
// the work even when a repo still has paths configured.
|
||||
if (settings.experimentalWorktreeSymlinks && repo.symlinkPaths && repo.symlinkPaths.length > 0) {
|
||||
await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths)
|
||||
const symlinkPaths = repo.symlinkPaths ?? []
|
||||
if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) {
|
||||
await timing.time('create_symlinks', async () => {
|
||||
await createWorktreeSymlinks(repo.path, created.path, symlinkPaths)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the worktree's own `orca.yaml` (at the tip of the base branch) is
|
||||
|
|
@ -1844,53 +1985,72 @@ export async function createLocalWorktree(
|
|||
// the regression this replaced.
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
const createdYamlHooks = loadHooks(worktreePath)
|
||||
const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks)
|
||||
try {
|
||||
defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
|
||||
defaultTabs = createdYamlHooks?.defaultTabs
|
||||
? { tabs: createdYamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
const setupScript = createdEffectiveHooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
await timing.time('prepare_setup', async () => {
|
||||
const createdYamlHooks = loadHooks(worktreePath)
|
||||
const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks)
|
||||
try {
|
||||
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: if the target branch introduces setup hooks that the primary
|
||||
// checkout did not expose, the renderer may not have collected an ask
|
||||
// decision. The worktree already exists, so skip setup instead of
|
||||
// turning successful git creation into an IPC failure.
|
||||
console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error)
|
||||
// Why: default tab commands share setup's run policy. If the target branch
|
||||
// adds commands without a renderer decision, create the tabs but don't run them.
|
||||
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
|
||||
defaultTabs = createdYamlHooks?.defaultTabs
|
||||
? { tabs: createdYamlHooks.defaultTabs, runCommands: false }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
if (setupScript && shouldLaunchSetup) {
|
||||
try {
|
||||
// Why: setup now runs in a visible terminal owned by the renderer so users
|
||||
// can inspect failures, answer prompts, and rerun it. The main process only
|
||||
// resolves policy and writes the runner script; it must not execute setup
|
||||
// itself anymore or we would reintroduce the hidden background-hook behavior.
|
||||
//
|
||||
// Why: the git worktree already exists at this point. If runner generation
|
||||
// fails, surfacing the error as a hard create failure would lie to the UI
|
||||
// about the underlying git state and strand a real worktree on disk.
|
||||
// Degrade to "created without setup launch" instead.
|
||||
setup = createSetupRunnerScript(repo, worktreePath, setupScript)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
const setupScript = createdEffectiveHooks?.scripts.setup
|
||||
let shouldLaunchSetup = false
|
||||
if (setupScript) {
|
||||
try {
|
||||
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
} catch (error) {
|
||||
// Why: if the target branch introduces setup hooks that the primary
|
||||
// checkout did not expose, the renderer may not have collected an ask
|
||||
// decision. The worktree already exists, so skip setup instead of
|
||||
// turning successful git creation into an IPC failure.
|
||||
console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (setupScript && shouldLaunchSetup) {
|
||||
try {
|
||||
// Why: setup now runs in a visible terminal owned by the renderer so users
|
||||
// can inspect failures, answer prompts, and rerun it. The main process only
|
||||
// resolves policy and writes the runner script; it must not execute setup
|
||||
// itself anymore or we would reintroduce the hidden background-hook behavior.
|
||||
//
|
||||
// Why: the git worktree already exists at this point. If runner generation
|
||||
// fails, surfacing the error as a hard create failure would lie to the UI
|
||||
// about the underlying git state and strand a real worktree on disk.
|
||||
// Degrade to "created without setup launch" instead.
|
||||
setup = createSetupRunnerScript(repo, worktreePath, setupScript)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const stagedStartup = await timing.time('spawn_startup_terminal', () =>
|
||||
spawnLocalStartupAndSetupTerminals({
|
||||
runtime,
|
||||
worktree,
|
||||
startup: args.startup,
|
||||
setup,
|
||||
defaultTabs,
|
||||
settings,
|
||||
createdWithAgent: args.createdWithAgent
|
||||
})
|
||||
)
|
||||
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {}),
|
||||
...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}),
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {})
|
||||
...(addResult.localBaseRefRefresh
|
||||
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
|
||||
: {}),
|
||||
...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}),
|
||||
...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}),
|
||||
timing: timing.finish()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,8 @@ describe('registerWorktreeHandlers', () => {
|
|||
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||||
resolveManagedMrBase: ReturnType<typeof vi.fn>
|
||||
createTerminal: ReturnType<typeof vi.fn>
|
||||
splitTerminal: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -414,7 +416,18 @@ describe('registerWorktreeHandlers', () => {
|
|||
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
|
||||
reconcileWorktreeBaseStatus: vi.fn(),
|
||||
clearOptimisticReconcileToken: vi.fn(),
|
||||
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' })
|
||||
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' }),
|
||||
createTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'term-startup',
|
||||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||||
title: null,
|
||||
surface: 'visible'
|
||||
}),
|
||||
splitTerminal: vi.fn().mockResolvedValue({
|
||||
handle: 'term-setup',
|
||||
tabId: 'tab-startup',
|
||||
paneRuntimeId: -1
|
||||
})
|
||||
}
|
||||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||||
})
|
||||
|
|
@ -514,7 +527,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
comment: 'keep me',
|
||||
isPinned: true
|
||||
})
|
||||
expect(result).toEqual({ comment: 'keep me', isPinned: true })
|
||||
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
|
||||
})
|
||||
|
||||
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
|
||||
|
|
@ -545,7 +558,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'origin/main',
|
||||
false
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/improve-dashboard-2',
|
||||
branch: 'improve-dashboard-2'
|
||||
|
|
@ -625,7 +638,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'origin/main',
|
||||
false
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/feature-something',
|
||||
branch: 'feature/something'
|
||||
|
|
@ -679,6 +692,80 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('spawns a startup terminal and setup terminal after local worktree registration', async () => {
|
||||
addWorktreeMock.mockResolvedValue({})
|
||||
listWorktreesMock.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'def',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
createdWithAgent: 'claude',
|
||||
startup: {
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
}
|
||||
})) as {
|
||||
setup?: unknown
|
||||
startupTerminal?: { spawned: boolean; surface?: string }
|
||||
timing?: { phases: { phase: string }[] }
|
||||
}
|
||||
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
{
|
||||
command: 'claude --prefill test',
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
},
|
||||
activate: true
|
||||
}
|
||||
)
|
||||
expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'id:repo-1::/workspace/improve-dashboard',
|
||||
{
|
||||
title: 'Setup',
|
||||
command: 'bash /workspace/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: '/workspace/repo',
|
||||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||||
},
|
||||
activate: false
|
||||
}
|
||||
)
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
|
||||
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git_worktree_add',
|
||||
'list_created_worktree',
|
||||
'prepare_setup',
|
||||
'spawn_startup_terminal'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('checks out a selected existing local branch exactly', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
|
|
@ -728,7 +815,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'repo-1::/workspace/fix-bug-0',
|
||||
expect.objectContaining({ preserveBranchOnDelete: true })
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/fix-bug-0',
|
||||
branch: 'refs/heads/fix/bug-0'
|
||||
|
|
@ -779,7 +866,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/fix-bug-0-2',
|
||||
branch: 'refs/heads/fix/bug-0'
|
||||
|
|
@ -1055,7 +1142,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
displayName: 'Fix: dashboards for PRs'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
displayName: 'Fix: dashboards for PRs'
|
||||
})
|
||||
|
|
@ -1092,7 +1179,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
manualOrder: 123_456
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
|
|
@ -1126,7 +1213,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
createdWithAgent: 'codex'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
|
|
@ -1269,7 +1356,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1738/head'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
baseBranch: 'abc123',
|
||||
pushTarget: {
|
||||
remoteName: 'pr-prateek-orca',
|
||||
|
|
@ -1306,7 +1393,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
['rev-parse', '--verify', 'origin/feature/add-feature'],
|
||||
{ cwd: '/workspace/repo' }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
baseBranch: 'def456',
|
||||
headSha: 'def456',
|
||||
branchNameOverride: 'feature/add-feature',
|
||||
|
|
@ -1333,7 +1420,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(result).toEqual({ baseBranch: 'abc123' })
|
||||
expect(result).toMatchObject({ baseBranch: 'abc123' })
|
||||
})
|
||||
|
||||
it('falls back to refs/pull/<N>/head when branch fetch fails for a PR', async () => {
|
||||
|
|
@ -1370,7 +1457,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], {
|
||||
cwd: '/workspace/repo'
|
||||
})
|
||||
expect(result).toEqual({ baseBranch: 'abc123' })
|
||||
expect(result).toMatchObject({ baseBranch: 'abc123' })
|
||||
})
|
||||
|
||||
it('does not fall back to refs/pull/<N>/head when branch fetch hits a network failure', async () => {
|
||||
|
|
@ -1395,7 +1482,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
['fetch', 'origin', 'refs/pull/1849/head'],
|
||||
expect.anything()
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
error:
|
||||
'Failed to fetch origin/feat/onboarding-model-choice-782: fatal: unable to access repo: Could not resolve host: github.com'
|
||||
})
|
||||
|
|
@ -1420,7 +1507,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
sourceBranch: 'feature/mr',
|
||||
isCrossRepository: true
|
||||
})
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
baseBranch: 'fork-mr-sha',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
|
||||
})
|
||||
|
|
@ -1485,7 +1572,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
manualOrder: 123_456
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
|
|
@ -1755,7 +1842,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
isSparse: true,
|
||||
sparseDirectories: ['apps/mobile', 'packages/shared'],
|
||||
|
|
@ -2582,7 +2669,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'/workspace/improve-dashboard',
|
||||
'codex exec "long command"'
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/workspace/repo',
|
||||
|
|
@ -3198,7 +3285,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'origin/main',
|
||||
false
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
path: '/workspace/improve-dashboard-3',
|
||||
branch: 'improve-dashboard-3'
|
||||
|
|
@ -3259,7 +3346,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup'
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/improve-dashboard',
|
||||
|
|
@ -3369,7 +3456,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/improve-dashboard',
|
||||
|
|
@ -3505,7 +3592,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/improve-dashboard',
|
||||
|
|
@ -4105,7 +4192,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
expectedHead: 'def456'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ deleted: true })
|
||||
expect(result).toMatchObject({ deleted: true })
|
||||
expect(forceDeleteLocalBranchMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'feature/test',
|
||||
|
|
|
|||
|
|
@ -683,6 +683,7 @@ type RuntimePtyController = {
|
|||
cwd?: string
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
telemetry?: WorktreeStartupLaunch['telemetry']
|
||||
connectionId?: string | null
|
||||
worktreeId?: string
|
||||
preAllocatedHandle?: string
|
||||
|
|
@ -7905,7 +7906,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const terminal = await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: effectiveStartup.command,
|
||||
env: effectiveStartup.env
|
||||
env: effectiveStartup.env,
|
||||
telemetry: effectiveStartup.telemetry
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
|
|
@ -8315,7 +8317,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const terminal = await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: effectiveStartup.command,
|
||||
env: effectiveStartup.env
|
||||
env: effectiveStartup.env,
|
||||
telemetry: effectiveStartup.telemetry
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||||
|
|
@ -8542,7 +8545,8 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
|
||||
command: args.startup.command,
|
||||
env: args.startup.env
|
||||
env: args.startup.env,
|
||||
telemetry: args.startup.telemetry
|
||||
})
|
||||
if (args.startupDraftPaste) {
|
||||
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
|
||||
|
|
@ -9854,6 +9858,7 @@ export class OrcaRuntimeService {
|
|||
opts: {
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
telemetry?: WorktreeStartupLaunch['telemetry']
|
||||
title?: string
|
||||
focus?: boolean
|
||||
rendererBacked?: boolean
|
||||
|
|
@ -9910,6 +9915,7 @@ export class OrcaRuntimeService {
|
|||
cwd: worktree.path,
|
||||
command: opts.command,
|
||||
env,
|
||||
telemetry: opts.telemetry,
|
||||
connectionId: repo?.connectionId ?? null,
|
||||
worktreeId: worktree.id,
|
||||
preAllocatedHandle
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { createWorktreeCreateTimingRecorder } from './worktree-create-timing'
|
||||
|
||||
describe('createWorktreeCreateTimingRecorder', () => {
|
||||
it('records ordered phase timings and total duration', async () => {
|
||||
const samples = [100, 105, 112, 130, 144, 155]
|
||||
const recorder = createWorktreeCreateTimingRecorder(() => samples.shift() ?? 155)
|
||||
|
||||
const syncResult = recorder.timeSync('resolve_branch', () => 'branch')
|
||||
const asyncResult = await recorder.time('git_worktree_add', async () => 'created')
|
||||
|
||||
expect(syncResult).toBe('branch')
|
||||
expect(asyncResult).toBe('created')
|
||||
expect(recorder.finish()).toEqual({
|
||||
totalDurationMs: 55,
|
||||
phases: [
|
||||
{ phase: 'resolve_branch', startedAtMs: 5, durationMs: 7 },
|
||||
{ phase: 'git_worktree_add', startedAtMs: 30, durationMs: 14 }
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/types'
|
||||
|
||||
type TimingClock = () => number
|
||||
|
||||
export type WorktreeCreateTimingRecorder = {
|
||||
time<T>(phase: string, operation: () => Promise<T>): Promise<T>
|
||||
timeSync<T>(phase: string, operation: () => T): T
|
||||
finish(): WorktreeCreateTiming
|
||||
}
|
||||
|
||||
function defaultClock(): number {
|
||||
return performance.now()
|
||||
}
|
||||
|
||||
function clampDuration(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
function createPhase(
|
||||
phase: string,
|
||||
operationStartedAt: number,
|
||||
operationEndedAt: number,
|
||||
rootStartedAt: number
|
||||
): WorktreeCreateTimingPhase {
|
||||
return {
|
||||
phase,
|
||||
startedAtMs: clampDuration(operationStartedAt - rootStartedAt),
|
||||
durationMs: clampDuration(operationEndedAt - operationStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeCreateTimingRecorder(
|
||||
clock: TimingClock = defaultClock
|
||||
): WorktreeCreateTimingRecorder {
|
||||
const startedAt = clock()
|
||||
const phases: WorktreeCreateTimingPhase[] = []
|
||||
|
||||
const recordPhase = (phase: string, operationStartedAt: number): void => {
|
||||
phases.push(createPhase(phase, operationStartedAt, clock(), startedAt))
|
||||
}
|
||||
|
||||
return {
|
||||
async time<T>(phase: string, operation: () => Promise<T>): Promise<T> {
|
||||
const operationStartedAt = clock()
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
recordPhase(phase, operationStartedAt)
|
||||
}
|
||||
},
|
||||
timeSync<T>(phase: string, operation: () => T): T {
|
||||
const operationStartedAt = clock()
|
||||
try {
|
||||
return operation()
|
||||
} finally {
|
||||
recordPhase(phase, operationStartedAt)
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
return {
|
||||
totalDurationMs: clampDuration(clock() - startedAt),
|
||||
phases: [...phases]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1935,6 +1935,29 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
Boolean(tuiAgent) &&
|
||||
!effectiveBranchNameOverride &&
|
||||
!createDisplayName
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent: tuiAgent,
|
||||
prompt: submitStartupPrompt,
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
platform: CLIENT_PLATFORM
|
||||
})
|
||||
|
||||
// Why: backend startup is safe only when the launch command is
|
||||
// self-contained. Agents that need post-ready paste/follow-up stay on
|
||||
// the renderer path so prompt delivery is not skipped.
|
||||
const composerTelemetry: AgentStartedTelemetry = {
|
||||
agent_kind: tuiAgentToAgentKind(tuiAgent),
|
||||
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
const backendStartup =
|
||||
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
|
||||
? {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
telemetry: composerTelemetry
|
||||
}
|
||||
: undefined
|
||||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
|
|
@ -1957,7 +1980,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
resolvedInitialWorkspaceStatus,
|
||||
linkedGitLabMR ?? undefined,
|
||||
linkedGitLabIssue ?? undefined,
|
||||
undefined,
|
||||
backendStartup,
|
||||
pendingFirstAgentMessageRename
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
|
@ -1976,32 +1999,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
})
|
||||
}
|
||||
: undefined
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent: tuiAgent,
|
||||
prompt: submitStartupPrompt,
|
||||
cmdOverrides: settings?.agentCmdOverrides ?? {},
|
||||
platform: CLIENT_PLATFORM
|
||||
})
|
||||
|
||||
// Why: thread agent_started telemetry through the queued startup so
|
||||
// main fires the event after the spawn succeeds. The composer
|
||||
// "create" path is the new-workspace surface; request_kind is
|
||||
// `'new'` because this is always a fresh session (issue/PR-driven
|
||||
// follow-ups go through launch-work-item-direct.ts).
|
||||
// Why: when the composer is opened from onboarding, the first
|
||||
// `agent_started` must attribute to `onboarding` so D1 activation
|
||||
// can be measured against the funnel.
|
||||
const composerTelemetry: AgentStartedTelemetry = {
|
||||
agent_kind: tuiAgentToAgentKind(tuiAgent),
|
||||
launch_source: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
const backendSpawnedStartup = result.startupTerminal?.spawned === true
|
||||
const activation = activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
setup: result.setup,
|
||||
defaultTabs: result.defaultTabs,
|
||||
issueCommand,
|
||||
...(startupPlan
|
||||
...(startupPlan && !backendSpawnedStartup
|
||||
? {
|
||||
startup: {
|
||||
command: startupPlan.launchCommand,
|
||||
|
|
@ -2019,7 +2023,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
: {})
|
||||
})
|
||||
if (startupPlan) {
|
||||
if (startupPlan && !backendSpawnedStartup) {
|
||||
void ensureAgentStartupInTerminal({
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId: activation === false ? null : activation.primaryTabId,
|
||||
|
|
@ -2176,66 +2180,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
Boolean(agent) &&
|
||||
!effectiveBranchNameOverride &&
|
||||
!createDisplayName
|
||||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
selectedRepoIsGit ? baseBranch : undefined,
|
||||
effectiveSetupDecision,
|
||||
selectedRepoIsGit && sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
}
|
||||
: undefined,
|
||||
telemetrySource,
|
||||
createDisplayName,
|
||||
submitLinkedIssueNumber ?? undefined,
|
||||
submitLinkedPR ?? undefined,
|
||||
pushTarget,
|
||||
agent ?? undefined,
|
||||
linkedLinearIssue,
|
||||
effectiveBranchNameOverride,
|
||||
resolvedInitialWorkspaceStatus,
|
||||
linkedGitLabMR ?? undefined,
|
||||
linkedGitLabIssue ?? undefined,
|
||||
undefined,
|
||||
pendingFirstAgentMessageRename
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
||||
const trimmedNote = note.trim()
|
||||
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
|
||||
|
||||
// Why: quick create should draft linked source data for review instead
|
||||
// of auto-executing it. Rich linked context wins over URL fallback;
|
||||
// typed-only Linear entries still use the note as the startup prompt.
|
||||
// Why: backend startup is safe only when the launch command is
|
||||
// self-contained. Agents that need post-ready paste/follow-up stay on
|
||||
// the renderer path so prompt delivery is not skipped.
|
||||
const { prompt: quickPrompt, draftPrompt: quickDraftPrompt } =
|
||||
resolveQuickCreateLinkedWorkItemPrompt(submitLinkedWorkItem, trimmedNote)
|
||||
|
||||
// Why: agents that gate first-launch behind a "Do you trust this
|
||||
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
|
||||
// as menu input. Pre-write the trust artifact so the menu is
|
||||
// skipped — best-effort, errors swallowed by main. Guard the IPC
|
||||
// presence so a stale preload bundle doesn't crash the launch with
|
||||
// "Cannot read properties of undefined".
|
||||
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
|
||||
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
|
||||
if (preflight) {
|
||||
try {
|
||||
await window.api.agentTrust.markTrusted({
|
||||
preset: preflight,
|
||||
workspacePath: worktree.path
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: continue with launch.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: prefer the agent's native prefill flag (currently Claude's
|
||||
// `--prefill`) when it has one — sidesteps the readiness/paste race
|
||||
// entirely. Falls through to the type-after-ready path for every
|
||||
// other agent.
|
||||
const draftLaunchPlan =
|
||||
agent === null || !quickDraftPrompt
|
||||
? null
|
||||
|
|
@ -2268,10 +2218,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
}
|
||||
|
||||
// Why: only attach telemetry when an agent was selected — the
|
||||
// quick path also handles "blank shell" (agent === null) where no
|
||||
// agent_started event should fire. When telemetry is present main
|
||||
// emits the event after pty:spawn succeeds.
|
||||
const quickTelemetry: AgentStartedTelemetry | null =
|
||||
agent === null
|
||||
? null
|
||||
|
|
@ -2281,11 +2227,69 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
const backendStartup =
|
||||
startupPlan && !startupPlan.draftPrompt && !startupPlan.followupPrompt
|
||||
? {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
|
||||
}
|
||||
: undefined
|
||||
const result = await createWorktree(
|
||||
repoId,
|
||||
workspaceName,
|
||||
selectedRepoIsGit ? baseBranch : undefined,
|
||||
effectiveSetupDecision,
|
||||
selectedRepoIsGit && sparseEnabled
|
||||
? {
|
||||
directories: normalizedSparseDirectories,
|
||||
...(effectivePresetId ? { presetId: effectivePresetId } : {})
|
||||
}
|
||||
: undefined,
|
||||
telemetrySource,
|
||||
createDisplayName,
|
||||
submitLinkedIssueNumber ?? undefined,
|
||||
submitLinkedPR ?? undefined,
|
||||
pushTarget,
|
||||
agent ?? undefined,
|
||||
linkedLinearIssue,
|
||||
effectiveBranchNameOverride,
|
||||
resolvedInitialWorkspaceStatus,
|
||||
linkedGitLabMR ?? undefined,
|
||||
linkedGitLabIssue ?? undefined,
|
||||
backendStartup,
|
||||
pendingFirstAgentMessageRename
|
||||
)
|
||||
const worktree = result.worktree
|
||||
|
||||
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
|
||||
|
||||
// Why: agents that gate first-launch behind a "Do you trust this
|
||||
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
|
||||
// as menu input. Pre-write the trust artifact so the menu is
|
||||
// skipped — best-effort, errors swallowed by main. Guard the IPC
|
||||
// presence so a stale preload bundle doesn't crash the launch with
|
||||
// "Cannot read properties of undefined".
|
||||
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
|
||||
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
|
||||
if (preflight) {
|
||||
try {
|
||||
await window.api.agentTrust.markTrusted({
|
||||
preset: preflight,
|
||||
workspacePath: worktree.path
|
||||
})
|
||||
} catch {
|
||||
// Best-effort: continue with launch.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backendSpawnedStartup = result.startupTerminal?.spawned === true
|
||||
const activation = activateAndRevealWorktree(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
setup: result.setup,
|
||||
defaultTabs: result.defaultTabs,
|
||||
...(startupPlan
|
||||
...(startupPlan && !backendSpawnedStartup
|
||||
? {
|
||||
startup: {
|
||||
command: startupPlan.launchCommand,
|
||||
|
|
@ -2303,7 +2307,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
|||
}
|
||||
: {})
|
||||
})
|
||||
if (startupPlan) {
|
||||
if (startupPlan && !backendSpawnedStartup) {
|
||||
void ensureAgentStartupInTerminal({
|
||||
worktreeId: worktree.id,
|
||||
primaryTabId: activation === false ? null : activation.primaryTabId,
|
||||
|
|
|
|||
|
|
@ -1956,6 +1956,73 @@ describe('worktree remote runtime mutations', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('passes startup commands through local worktree creation IPC', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/path/local-agent-startup',
|
||||
repoId: 'repo1',
|
||||
path: '/path/local-agent-startup'
|
||||
})
|
||||
mockApi.worktrees.create.mockResolvedValue({
|
||||
worktree: wt,
|
||||
startupTerminal: { spawned: true, surface: 'visible' }
|
||||
})
|
||||
store.setState({
|
||||
worktreesByRepo: { repo1: [] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.createWorktree(
|
||||
'repo1',
|
||||
'local-agent-startup',
|
||||
undefined,
|
||||
'skip',
|
||||
undefined,
|
||||
'sidebar',
|
||||
'Launch local agent',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'claude',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
command: "claude --prefill 'summarize repo'",
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoId: 'repo1',
|
||||
name: 'local-agent-startup',
|
||||
setupDecision: 'skip',
|
||||
telemetrySource: 'sidebar',
|
||||
displayName: 'Launch local agent',
|
||||
createdWithAgent: 'claude',
|
||||
startup: {
|
||||
command: "claude --prefill 'summarize repo'",
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not suffix branchNameOverride when runtime create reports a branch conflict', async () => {
|
||||
const store = createTestStore()
|
||||
runtimeEnvironmentCall.mockRejectedValueOnce(new Error('Branch already exists on a remote'))
|
||||
|
|
|
|||
|
|
@ -1064,7 +1064,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
...(manualOrder !== undefined ? { manualOrder } : {}),
|
||||
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
|
||||
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
|
||||
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {})
|
||||
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
|
||||
...(startup ? { startup } : {})
|
||||
}
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const result =
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
RepoSourceControlAiOverrides,
|
||||
SourceControlAiSettings
|
||||
} from './source-control-ai-types'
|
||||
import type { AgentKind, LaunchSource, RequestKind } from './telemetry-events'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
|
|
@ -1541,6 +1542,7 @@ export type WorktreeSetupLaunch = {
|
|||
export type WorktreeStartupLaunch = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind }
|
||||
}
|
||||
|
||||
export type WorktreeDefaultTabsLaunch = {
|
||||
|
|
@ -1548,6 +1550,17 @@ export type WorktreeDefaultTabsLaunch = {
|
|||
runCommands: boolean
|
||||
}
|
||||
|
||||
export type WorktreeCreateTimingPhase = {
|
||||
phase: string
|
||||
startedAtMs: number
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export type WorktreeCreateTiming = {
|
||||
totalDurationMs: number
|
||||
phases: WorktreeCreateTimingPhase[]
|
||||
}
|
||||
|
||||
export type CreateSparseCheckoutRequest = {
|
||||
directories: string[]
|
||||
/** Set when the directories came from a saved preset and the user did not
|
||||
|
|
@ -1604,6 +1617,9 @@ export type CreateWorktreeArgs = {
|
|||
* pre-date this prop default to `unknown` at the IPC boundary instead
|
||||
* of failing typecheck. */
|
||||
telemetrySource?: WorkspaceSource
|
||||
/** Optional startup command for callers that want the backend to spawn the
|
||||
* first terminal as soon as the worktree is registered. */
|
||||
startup?: WorktreeStartupLaunch
|
||||
}
|
||||
|
||||
export type CreateWorktreeResult = {
|
||||
|
|
@ -1620,6 +1636,11 @@ export type CreateWorktreeResult = {
|
|||
warning?: string
|
||||
initialBaseStatus?: WorktreeBaseStatusEvent
|
||||
localBaseRefRefresh?: LocalBaseRefRefreshResult
|
||||
startupTerminal?: {
|
||||
spawned: boolean
|
||||
surface?: 'visible' | 'background'
|
||||
}
|
||||
timing?: WorktreeCreateTiming
|
||||
}
|
||||
|
||||
export type PreservedWorktreeBranch = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue