fix(vm): preserve host through workspace completion

This commit is contained in:
Jinwoo-H
2026-08-13 06:30:55 -04:00
parent 8f7ab40bde
commit 2bc422a464
8 changed files with 309 additions and 65 deletions
+3
View File
@@ -773,6 +773,9 @@
},
"ephemeralVmWorktreeCreation": {
"sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout."
},
"worktreeCreationFlow": {
"backgroundStartupOwnerAmbiguous": "Workspace created, but automatic startup was skipped because its host is ambiguous."
}
},
"hooks": {
@@ -0,0 +1,25 @@
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
export async function preflightWorktreeCreationAgentTrust(
request: WorktreeCreationRequest,
path: string,
connectionId?: string | null
): Promise<void> {
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: path,
...(connectionId ? { connectionId } : {})
})
} catch {
// Best-effort: the created workspace must remain usable if trust persistence fails.
}
}
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), 'utf8')
const TRUST_SOURCE = readFileSync(join(__dirname, 'worktree-creation-agent-trust.ts'), 'utf8')
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -14,23 +15,19 @@ function sourceBetween(source: string, startPattern: string, endPattern: string)
describe('worktree creation flow agent trust preflight', () => {
it('forwards the repo SSH connection id when pre-marking agent trust', () => {
const preflight = sourceBetween(
FLOW_SOURCE,
'async function preflightAgentTrust',
'async function executeWorktreeCreation'
)
const createFlow = sourceBetween(
FLOW_SOURCE,
'const backendSpawned = result.startupTerminal?.spawned === true',
'// `createWorktree` already inserted the real worktree row'
)
expect(preflight).toContain('connectionId?: string | null')
expect(preflight).toContain('...(connectionId ? { connectionId } : {})')
expect(TRUST_SOURCE).toContain('connectionId?: string | null')
expect(TRUST_SOURCE).toContain('...(connectionId ? { connectionId } : {})')
expect(createFlow).toContain('repoConnectionId')
expect(createFlow).toContain('repo.id === worktree.repoId')
expect(createFlow).toContain('findRepoForHost(trustState.repos, worktree.repoId, {')
expect(createFlow).toContain('hostId: worktree.hostId')
expect(createFlow).toContain(
'await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)'
'await preflightWorktreeCreationAgentTrust(preparedRequest, worktree.path, repoConnectionId)'
)
})
})
@@ -0,0 +1,143 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { PendingWorktreeCreation, WorktreeCreationRequest } from './pending-worktree-creation'
const mocks = vi.hoisted(() => {
const store = {
activePendingCreationId: null as string | null,
activeView: 'terminal',
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
repos: [],
settings: {},
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
store.pendingWorktreeCreations[entry.creationId] = entry
store.activePendingCreationId = entry.creationId
}),
updatePendingWorktreeCreation: vi.fn(),
removePendingWorktreeCreation: vi.fn((creationId: string) => {
delete store.pendingWorktreeCreations[creationId]
}),
createWorktree: vi.fn(),
updateWorktreeMeta: vi.fn(),
setActivePendingWorktreeCreation: vi.fn(),
setActiveView: vi.fn(),
setSidebarOpen: vi.fn()
}
return {
store,
activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: null })),
ensureWorktreeHasInitialTerminal: vi.fn(),
toastError: vi.fn(),
seedAgentTabState: vi.fn(),
ensureAgentStartup: vi.fn(),
resolveExplicitRoute: vi.fn<
() =>
| {
kind: 'resolved'
route: { executionHostId: 'ssh:requested'; runtimeEnvironmentId: null }
}
| { kind: 'ambiguous' }
>(() => ({
kind: 'resolved',
route: { executionHostId: 'ssh:requested', runtimeEnvironmentId: null }
})),
prepareRequestForCreate: vi.fn(
async (_creationId: string, request: WorktreeCreationRequest) => request
)
}
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } }))
vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'creation-host-routing' }))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal
}))
vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({
prepareRequestForCreate: mocks.prepareRequestForCreate,
adoptEphemeralVmProvisionedRoot: vi.fn(),
attachEphemeralVmRuntimeToWorkspace: vi.fn(),
cleanupEphemeralVmRuntimeForFailedCreate: vi.fn()
}))
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
seedAgentTabStateAfterWorktreeCreate: mocks.seedAgentTabState
}))
vi.mock('@/lib/worktree-operation-route', () => ({
resolveExplicitWorktreeOperationRouteResult: mocks.resolveExplicitRoute
}))
vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: mocks.ensureAgentStartup }))
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
queueWorkspaceActivationTerminalFocus: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
beforeEach(() => {
vi.clearAllMocks()
mocks.store.activePendingCreationId = null
mocks.store.activeView = 'terminal'
mocks.store.pendingWorktreeCreations = {}
mocks.resolveExplicitRoute.mockReturnValue({
kind: 'resolved',
route: { executionHostId: 'ssh:requested', runtimeEnvironmentId: null }
})
mocks.store.createWorktree.mockResolvedValue({
worktree: { id: 'wt-shared', repoId: 'repo-1', hostId: 'ssh:requested' }
})
})
it('fails background startup closed when duplicate hosts make the bare id ambiguous', async () => {
mocks.store.activeView = 'tasks'
mocks.resolveExplicitRoute.mockReturnValue({ kind: 'ambiguous' })
runBackgroundWorktreeCreation({
repoId: 'repo-1',
name: 'remote-workspace',
setupDecision: 'inherit',
agent: 'codex',
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: {
agent: 'codex',
launchCommand: 'codex',
expectedProcess: 'codex',
followupPrompt: null,
launchConfig: { agent: 'codex', command: 'codex' }
} as never,
quickPrompt: '',
quickTelemetry: null
})
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
expect(mocks.seedAgentTabState).not.toHaveBeenCalled()
expect(mocks.ensureAgentStartup).not.toHaveBeenCalled()
})
it('keeps remote host ownership through activation and note persistence', async () => {
runBackgroundWorktreeCreation({
repoId: 'repo-1',
name: 'remote-workspace',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: 'Remote note',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null
})
await vi.waitFor(() =>
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-shared', {
sidebarRevealBehavior: 'auto',
executionHostId: 'ssh:requested'
})
)
await vi.waitFor(() =>
expect(mocks.store.updateWorktreeMeta).toHaveBeenCalledWith(
'wt-shared',
{ comment: 'Remote note' },
{ executionHostId: 'ssh:requested' }
)
)
})
+52 -56
View File
@@ -1,6 +1,6 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { translate } from '@/i18n/i18n'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
@@ -27,6 +27,13 @@ import {
import { createBrowserUuid } from '@/lib/browser-uuid'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { persistCreatedWorktreeNote } from '@/lib/worktree-creation-note'
import { preflightWorktreeCreationAgentTrust } from '@/lib/worktree-creation-agent-trust'
import {
canSeedCreatedWorktreeInBackground,
hasCreatedWorktreeTerminalHandoff
} from '@/lib/worktree-creation-terminal-handoff'
import {
buildWorktreeCreationStartupOpt,
getInitialWorktreeCreationPhase,
@@ -68,33 +75,6 @@ function revealPendingCreation(
store.setSidebarOpen(true)
}
async function preflightAgentTrust(
request: WorktreeCreationRequest,
path: string,
connectionId?: string | null
): Promise<void> {
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
// as menu input on first launch. Pre-write the trust artifact before any
// terminal spawns. Best-effort — the worktree already exists, so a failure
// here must not strand it.
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: path,
...(connectionId ? { connectionId } : {})
})
} catch {
// Best-effort: continue with launch.
}
}
async function executeWorktreeCreation(
creationId: string,
request: WorktreeCreationRequest
@@ -196,9 +176,13 @@ async function executeWorktreeCreation(
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
if (worktree.path) {
const trustState = useAppStore.getState()
const repoConnectionId =
useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null
await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)
findRepoForHost(trustState.repos, worktree.repoId, {
hostId: worktree.hostId,
settings: trustState.settings
})?.connectionId ?? null
await preflightWorktreeCreationAgentTrust(preparedRequest, worktree.path, repoConnectionId)
}
// `createWorktree` already inserted the real worktree row. Leaving for an app
@@ -214,45 +198,63 @@ async function executeWorktreeCreation(
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
let terminalHandoffSafe = true
if (shouldActivateOnCompletion) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(worktree.hostId ? { executionHostId: worktree.hostId } : {}),
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {}),
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
})
terminalHandoffSafe = activation !== false
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// The user moved on. Seed the worktree's terminal + setup in the background
// (setActiveTab only writes global focus for the active worktree, so this is
// safe) without yanking them back to it.
primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
preparedRequest.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
terminalHandoffSafe = canSeedCreatedWorktreeInBackground(completionState, worktree)
if (!terminalHandoffSafe) {
primaryTabId = null
if (hasCreatedWorktreeTerminalHandoff(preparedRequest, result)) {
toast.error(
translate(
'auto.lib.worktreeCreationFlow.backgroundStartupOwnerAmbiguous',
'Workspace created, but automatic startup was skipped because its host is ambiguous.'
)
)
}
)
} else {
primaryTabId = ensureWorktreeHasInitialTerminal(
completionState,
worktree.id,
startupOpt,
result.setup,
preparedRequest.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
}
}
// Why: clearing synchronously right after activation lets React commit the
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false })
seedAgentTabStateAfterWorktreeCreate({
request: preparedRequest,
worktreeId: worktree.id,
primaryTabId,
startupTerminalTabId: result.startupTerminal?.tabId,
backendSpawned
})
if (preparedRequest.startupPlan && !backendSpawned) {
if (terminalHandoffSafe) {
seedAgentTabStateAfterWorktreeCreate({
request: preparedRequest,
worktreeId: worktree.id,
primaryTabId,
startupTerminalTabId: result.startupTerminal?.tabId,
backendSpawned
})
}
if (terminalHandoffSafe && preparedRequest.startupPlan && !backendSpawned) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId,
@@ -266,13 +268,7 @@ async function executeWorktreeCreation(
// Why: awaiting the note IPC before the swap would add a visible round-trip to
// the panel→terminal transition; it's cosmetic, so it runs last.
if (preparedRequest.note) {
try {
await useAppStore.getState().updateWorktreeMeta(worktree.id, {
comment: preparedRequest.note
})
} catch {
console.error('Failed to update worktree meta after creation')
}
await persistCreatedWorktreeNote(worktree, preparedRequest.note)
}
}
@@ -0,0 +1,19 @@
import { useAppStore } from '@/store'
import type { Worktree } from '../../../shared/types'
export async function persistCreatedWorktreeNote(
worktree: Pick<Worktree, 'id' | 'hostId'>,
note: string
): Promise<void> {
try {
await useAppStore
.getState()
.updateWorktreeMeta(
worktree.id,
{ comment: note },
worktree.hostId ? { executionHostId: worktree.hostId } : undefined
)
} catch {
console.error('Failed to update worktree meta after creation')
}
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { canSeedCreatedWorktreeInBackground } from './worktree-creation-terminal-handoff'
function stateWithOwners(owners: { id: string; repoId: string; hostId?: string }[]): never {
return {
repos: owners.map((owner) => ({
id: owner.repoId,
connectionId: owner.hostId?.startsWith('ssh:') ? owner.hostId.slice(4) : null,
executionHostId: owner.hostId
})),
worktreesByRepo: { repo: owners },
detectedWorktreesByRepo: {},
folderWorkspaces: [],
projectGroups: [],
runtimeEnvironments: [],
settings: {}
} as never
}
describe('created worktree background terminal handoff', () => {
it('fails closed when sibling hosts publish the same worktree id', () => {
const id = 'repo::/workspace'
const state = stateWithOwners([
{ id, repoId: 'repo', hostId: 'ssh:first' },
{ id, repoId: 'repo', hostId: 'ssh:second' }
])
expect(canSeedCreatedWorktreeInBackground(state, { id, hostId: 'ssh:second' })).toBe(false)
})
it('allows a uniquely resolved host and preserves legacy hostless behavior', () => {
const id = 'repo::/workspace'
const state = stateWithOwners([{ id, repoId: 'repo', hostId: 'ssh:requested' }])
expect(canSeedCreatedWorktreeInBackground(state, { id, hostId: 'ssh:requested' })).toBe(true)
expect(canSeedCreatedWorktreeInBackground(state, { id })).toBe(true)
})
})
@@ -0,0 +1,23 @@
import type { CreateWorktreeResult, Worktree } from '../../../shared/types'
import type { WorktreeCreationRequest } from './pending-worktree-creation'
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-route'
type RouteState = Parameters<typeof resolveExplicitWorktreeOperationRouteResult>[0]
export function canSeedCreatedWorktreeInBackground(
state: RouteState,
worktree: Pick<Worktree, 'id' | 'hostId'>
): boolean {
if (!worktree.hostId) {
return true
}
const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktree.id)
return resolution.kind === 'resolved' && resolution.route.executionHostId === worktree.hostId
}
export function hasCreatedWorktreeTerminalHandoff(
request: WorktreeCreationRequest,
result: CreateWorktreeResult
): boolean {
return Boolean(request.startupPlan || result.setup || request.issueCommand || result.defaultTabs)
}