diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c9fea250709..0c92b681fd4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/lib/worktree-creation-agent-trust.ts b/src/renderer/src/lib/worktree-creation-agent-trust.ts new file mode 100644 index 00000000000..96596b913c8 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-trust.ts @@ -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 { + 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. + } +} diff --git a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts b/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts index 928c3dfc87f..8870e7b9702 100644 --- a/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-agent-trust-preflight.test.ts @@ -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)' ) }) }) diff --git a/src/renderer/src/lib/worktree-creation-flow-host-routing.test.ts b/src/renderer/src/lib/worktree-creation-flow-host-routing.test.ts new file mode 100644 index 00000000000..71f9a40f106 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-host-routing.test.ts @@ -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, + 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' } + ) + ) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index b8748067479..94e7bb29116 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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 { - // 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) } } diff --git a/src/renderer/src/lib/worktree-creation-note.ts b/src/renderer/src/lib/worktree-creation-note.ts new file mode 100644 index 00000000000..9db439b1654 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-note.ts @@ -0,0 +1,19 @@ +import { useAppStore } from '@/store' +import type { Worktree } from '../../../shared/types' + +export async function persistCreatedWorktreeNote( + worktree: Pick, + note: string +): Promise { + 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') + } +} diff --git a/src/renderer/src/lib/worktree-creation-terminal-handoff.test.ts b/src/renderer/src/lib/worktree-creation-terminal-handoff.test.ts new file mode 100644 index 00000000000..7951ddca621 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-terminal-handoff.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-terminal-handoff.ts b/src/renderer/src/lib/worktree-creation-terminal-handoff.ts new file mode 100644 index 00000000000..1f51c80cd9c --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-terminal-handoff.ts @@ -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[0] + +export function canSeedCreatedWorktreeInBackground( + state: RouteState, + worktree: Pick +): 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) +}