From 40c7a6d38d9680510ea2a1e074364536beb5f07e Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 15:40:39 +1100 Subject: [PATCH] fix(worktree): gate sessions on bootstrap readiness --- .../components/session/NewWorktreeDialog.tsx | 3 + packages/ui/src/lib/worktreeSessionCreator.ts | 5 + .../ui/src/stores/useMultiRunStore.test.ts | 32 +++- packages/ui/src/stores/useMultiRunStore.ts | 3 + packages/ui/src/sync/session-ui-store.ts | 147 ++++++++++++------ packages/vscode/src/gitService.ts | 18 +++ packages/web/server/lib/git/service.js | 18 +++ packages/web/server/lib/git/service.test.js | 47 ++++++ 8 files changed, 216 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 70be749dfc..774f404593 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -33,6 +33,7 @@ import * as sessionActions from '@/sync/session-actions'; import { useConfigStore } from '@/stores/useConfigStore'; import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager'; import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; @@ -856,6 +857,8 @@ export function NewWorktreeDialog({ let createdSessionId: string | null = null; if (shouldCreateSession) { + await waitForWorktreeBootstrap(metadata.path); + const sessionTitle = linkedIssue ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 1e152559f9..1d8d385da1 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -25,6 +25,7 @@ import { rejectPendingDraftWorktreeRequest, resolvePendingDraftWorktreeRequest, } from '@/lib/worktrees/pendingDraftWorktree'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value; @@ -417,6 +418,8 @@ export async function createWorktreeSessionForBranch( kind, }; + await waitForWorktreeBootstrap(metadata.path); + // Create the session const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); @@ -520,6 +523,8 @@ export async function createWorktreeSessionForNewBranch( kind, }; + await waitForWorktreeBootstrap(metadata.path); + const sessionStore = useSessionUIStore.getState(); const session = await sessionStore.createSession(undefined, metadata.path); if (!session) { diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts index 6af180c6b7..339b40736e 100644 --- a/packages/ui/src/stores/useMultiRunStore.test.ts +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -6,6 +6,8 @@ const registeredDirectories: Array<{ sessionID: string; directory: string }> = [ const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = []; const worktreeMetadataCalls: Array<{ sessionId: string; path: string }> = []; const worktreeCreateCalls: Array<{ project: { id?: string; path: string }; args: Record; options: unknown }> = []; +const worktreeBootstrapWaitCalls: string[] = []; +const operationOrder: string[] = []; let isGitRepository = false; const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record, options: unknown) => { worktreeCreateCalls.push({ project, args, options }); @@ -52,12 +54,15 @@ mock.module('@/lib/opencode/client', () => ({ currentDirectory = previous; } }, - createSession: async (params?: { title?: string }): Promise => ({ - id: 'ses_multirun', - title: params?.title ?? '', - directory: currentDirectory, - time: { created: 1, updated: 1 }, - } as Session), + createSession: async (params?: { title?: string }): Promise => { + operationOrder.push(`createSession:${currentDirectory}`); + return { + id: 'ses_multirun', + title: params?.title ?? '', + directory: currentDirectory, + time: { created: 1, updated: 1 }, + } as Session; + }, }, })); @@ -70,6 +75,14 @@ mock.module('@/lib/worktrees/worktreeCreate', () => ({ resolveRootTrackingRemote: mock(() => Promise.resolve(null)), })); +mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ + waitForWorktreeBootstrap: (directory: string) => { + worktreeBootstrapWaitCalls.push(directory); + operationOrder.push(`wait:${directory}`); + return Promise.resolve(); + }, +})); + mock.module('@/lib/worktrees/worktreeStatus', () => ({ getRootBranch: mock(() => Promise.resolve('main')), })); @@ -139,6 +152,8 @@ describe('useMultiRunStore', () => { ensureChildCalls.length = 0; worktreeMetadataCalls.length = 0; worktreeCreateCalls.length = 0; + worktreeBootstrapWaitCalls.length = 0; + operationOrder.length = 0; isGitRepository = false; childState.session = []; childState.sessionTotal = 0; @@ -181,6 +196,11 @@ describe('useMultiRunStore', () => { expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' }); expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true); expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null }); + expect(worktreeBootstrapWaitCalls).toEqual(['/repo-worktrees/fix-thing']); + expect(operationOrder).toEqual([ + 'wait:/repo-worktrees/fix-thing', + 'createSession:/repo-worktrees/fix-thing', + ]); expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]); expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]); }); diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 310e29b9d2..03e99d0453 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -7,6 +7,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate'; +import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useDirectoryStore } from './useDirectoryStore'; @@ -244,6 +245,8 @@ export const useMultiRunStore = create()( kind: 'standard' as const, }; + await waitForWorktreeBootstrap(worktreeMetadata.path); + const session = await opencodeClient.withDirectory( worktreeMetadata.path, () => opencodeClient.createSession({ title: sessionTitle }), diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 374dc1a0fb..7774a6ae53 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -29,6 +29,7 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" +import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" import { resolveProjectForSessionDirectory } from "@/lib/projectResolution" import { getSyncSessions, @@ -401,6 +402,84 @@ const writeRuntimeSessionMemory = (key: string, patch: Partial { + const store = useSessionUIStore.getState() + const draft = store.newSessionDraft + if (!draft?.open) return null + + const trimmedAgent = typeof selection.agent === "string" && selection.agent.trim().length > 0 + ? selection.agent.trim() + : undefined + const draftTargetFolderId = draft.targetFolderId + let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null + const draftProjectId = draft.selectedProjectId ?? null + + if (draft.pendingWorktreeRequestId) { + draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId) + store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) + } + + if (draftDirectoryOverride) { + await waitForWorktreeBootstrap(draftDirectoryOverride) + } + + const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) + if (!created?.id) throw new Error("Failed to create session") + + persistDraftTarget({ + projectId: draftProjectId, + directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), + }) + + const draftSyntheticParts = draft.syntheticParts + const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) + const configState = useConfigStore.getState() + void activateConfigForDirectory(createdDirectory).catch((error) => { + console.warn("Failed to activate directory after creating session:", error) + }) + + const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName + + useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID) + + if (effectiveDraftAgent) { + useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent) + useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID) + useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant) + } + + store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) + + store.closeNewSessionDraft() + store.setCurrentSession(created.id, createdDirectory) + + if (draftTargetFolderId) { + const scopeKey = draftDirectoryOverride || created.directory || null + if (scopeKey) { + useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id) + } + } + + return { + sessionId: created.id, + directory: createdDirectory, + agent: effectiveDraftAgent, + syntheticParts: draftSyntheticParts, + } +} + // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -913,59 +992,21 @@ export const useSessionUIStore = create()((set, get) => ({ // ---- New session from draft ---- if (!options?.sessionId && draft?.open) { - const draftTargetFolderId = draft.targetFolderId - let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null - const draftProjectId = draft.selectedProjectId ?? null - - if (draft.pendingWorktreeRequestId) { - draftDirectoryOverride = await waitForPendingDraftWorktreeRequest(draft.pendingWorktreeRequestId) - get().resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) - } - - const created = await get().createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) - if (!created?.id) throw new Error("Failed to create session") - - persistDraftTarget({ - projectId: draftProjectId, - directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), - }) - - const draftSyntheticParts = draft.syntheticParts - const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) - const configState = useConfigStore.getState() - void activateConfigForDirectory(createdDirectory).catch((error) => { - console.warn("Failed to activate directory after creating session:", error) + const createdDraftSession = await materializeOpenDraftSession({ + providerID, + modelID, + agent: trimmedAgent, + variant, }) + if (!createdDraftSession) throw new Error("Failed to create session") - const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName - - useSelectionStore.getState().saveSessionModelSelection(created.id, providerID, modelID) - - if (effectiveDraftAgent) { - useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent) - useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, providerID, modelID) - useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, providerID, modelID, variant) - } - - get().initializeNewOpenChamberSession(created.id, configState.agents ?? []) - - get().closeNewSessionDraft() - get().setCurrentSession(created.id, createdDirectory) - - if (draftTargetFolderId) { - const scopeKey = draftDirectoryOverride || created.directory || null - if (scopeKey) { - useSessionFoldersStore.getState().addSessionToFolder(scopeKey, draftTargetFolderId, created.id) - } - } - - const mergedAdditionalParts = draftSyntheticParts?.length - ? [...(additionalParts || []), ...draftSyntheticParts] + const mergedAdditionalParts = createdDraftSession.syntheticParts?.length + ? [...(additionalParts || []), ...createdDraftSession.syntheticParts] : additionalParts - notifyMessageSent(created.id) + notifyMessageSent(createdDraftSession.sessionId) - markPendingUserSendAnimation(created.id) + markPendingUserSendAnimation(createdDraftSession.sessionId) const files = attachments?.map((a) => ({ type: "file" as const, @@ -975,12 +1016,12 @@ export const useSessionUIStore = create()((set, get) => ({ })) await routeMessage({ - sessionId: created.id, - directory: createdDirectory, + sessionId: createdDraftSession.sessionId, + directory: createdDraftSession.directory, content, providerID, modelID, - agent: effectiveDraftAgent, + agent: createdDraftSession.agent, agentMentionName, variant, inputMode, @@ -1333,6 +1374,10 @@ export const useSessionUIStore = create()((set, get) => ({ returnAfterDirectoryCreated: true, }) sessionDirectory = normalizePath(createdWorktree.path) + if (!sessionDirectory) { + throw new Error("Worktree create missing name/path") + } + await waitForWorktreeBootstrap(sessionDirectory) } const session = await get().createSession(undefined, sessionDirectory || null, null) diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index a232ef7cd2..b765c149cd 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -1754,6 +1754,19 @@ export async function validateWorktreeCreate(directory: string, input: CreateGit } } +const assertWorktreeCreatePreflight = async (directory: string, input: CreateGitWorktreePayload = {}): Promise => { + const validation = await validateWorktreeCreate(directory, input); + if (validation?.ok) { + return; + } + + const message = validation?.errors + ?.map((error) => error?.message) + .filter(Boolean) + .join('\n') || 'Failed to validate worktree creation'; + throw new Error(message); +}; + export async function previewWorktreeCreate(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -1907,6 +1920,11 @@ async function attachGitWorktreeToCandidate( export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); + + if (input?.returnAfterDirectoryCreated === true) { + await assertWorktreeCreatePreflight(directory, input); + } + await fs.promises.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index ed86fd390f..5196c11532 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3544,6 +3544,19 @@ export async function validateWorktreeCreate(directory, input = {}) { } } +const assertWorktreeCreatePreflight = async (directory, input = {}) => { + const validation = await validateWorktreeCreate(directory, input); + if (validation?.ok) { + return; + } + + const message = validation?.errors + ?.map((error) => error?.message) + .filter(Boolean) + .join('\n') || 'Failed to validate worktree creation'; + throw new Error(message); +}; + export async function previewWorktreeCreate(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); @@ -3692,6 +3705,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { export async function createWorktree(directory, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; const context = await resolveWorktreeProjectContext(directory); + + if (input?.returnAfterDirectoryCreated === true) { + await assertWorktreeCreatePreflight(directory, input); + } + await fsp.mkdir(context.worktreeRoot, { recursive: true }); const preferredName = String(input?.worktreeName || input?.name || '').trim(); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index fb67b27694..5dabaf4c6b 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -8,6 +8,7 @@ import simpleGit from 'simple-git'; import { checkoutCommit, cherryPick, + createWorktree, getStatus, removeWorktree, resolvePrimaryWorktreeRoot, @@ -315,6 +316,52 @@ describe('worktree root resolution', () => { }); }); +// --------------------------------------------------------------------------- +// createWorktree +// --------------------------------------------------------------------------- + +describe('createWorktree', () => { + it('preflights fast create branch-in-use failures before creating the candidate directory', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + const worktree = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + const projectID = runGit(repo, ['rev-list', '--max-parents=0', '--all']).trim(); + + fs.rmSync(worktree, { recursive: true, force: true }); + runGit(repo, ['worktree', 'add', '-b', 'feature/in-use', worktree, 'HEAD']); + + await expect(createWorktree(repo, { + mode: 'existing', + existingBranch: 'feature/in-use', + branchName: 'feature/in-use', + worktreeName: 'feature-in-use', + returnAfterDirectoryCreated: true, + })).rejects.toThrow(`Branch is already checked out in ${worktree}`); + + const candidateDirectory = path.join(dataHome, 'opencode', 'worktree', projectID, 'feature-in-use'); + expect(fs.existsSync(candidateDirectory)).toBe(false); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); +}); + // --------------------------------------------------------------------------- // removeWorktree // ---------------------------------------------------------------------------