diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 9187cfb9..d06c6e5d 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -863,6 +863,31 @@ describe('WebSocket Chat Integration', () => { expect(statusVerbs).toContain('Creating worktree') }) + it('does not emit worktree startup status for an already materialized worktree session', async () => { + const repoDir = await createCleanGitRepo() + const { sessionId } = await sessionService.createSession(repoDir, { + branch: 'feature/rail', + worktree: true, + }) + + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId) + const worktreePath = launchInfo?.repository?.worktreePath + expect(worktreePath).toBeTruthy() + await fs.mkdir(worktreePath!, { recursive: true }) + await sessionService.appendSessionMetadata(sessionId, { + workDir: worktreePath!, + repository: launchInfo!.repository, + }) + + const messages = await runTurn(sessionId, 'Continue in the existing worktree') + const statusVerbs = messages + .filter((msg) => msg.type === 'status') + .map((msg) => msg.verb) + + expect(statusVerbs).toContain('Thinking') + expect(statusVerbs).not.toContain('Creating worktree') + }) + it('keeps the default startup status for current-worktree repository sessions', async () => { const repoDir = await createCleanGitRepo() const { sessionId } = await sessionService.createSession(repoDir, { diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 3d333bd0..febcba39 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -911,6 +911,26 @@ describe('SessionService', () => { expect(workDir).toBe('/tmp/latest-worktree') }) + it('should prefer the newest duplicate session file when worktree metadata moves', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const sourceFile = await writeSessionFile('-tmp-project', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry('/tmp/project'), + ]) + const worktreeFile = await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry('/tmp/project/.claude/worktrees/desktop-main-12345678'), + ]) + + const oldTime = new Date('2026-01-01T00:00:00.000Z') + const newTime = new Date('2026-01-01T00:00:01.000Z') + await fs.utimes(sourceFile, oldTime, oldTime) + await fs.utimes(worktreeFile, newTime, newTime) + + const workDir = await service.getSessionWorkDir(sessionId) + expect(workDir).toBe('/tmp/project/.claude/worktrees/desktop-main-12345678') + }) + it('should recover CLI worktree state from transcript metadata', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [ diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index a511eec8..2de58704 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -13,7 +13,9 @@ import { ProviderService } from './providerService.js' import { sessionService } from './sessionService.js' import { diagnosticsService } from './diagnosticsService.js' import { + isMaterializedWorktreeLaunch, prepareSessionWorkspace, + shouldCreateWorktreeForSessionLaunch, type PreparedSessionWorkspace, } from './repositoryLaunchService.js' import { @@ -146,6 +148,10 @@ export class ConversationService { const shouldResume = !!launchInfo && launchInfo.transcriptMessageCount > 0 const shouldReplacePlaceholder = !!launchInfo && launchInfo.transcriptMessageCount === 0 + const shouldCreateWorktree = + !!launchInfo && shouldCreateWorktreeForSessionLaunch(launchInfo) + const hasMaterializedWorktree = + !!launchInfo && isMaterializedWorktreeLaunch(launchInfo) if (this.deletedSessions.has(sessionId)) { throw new ConversationStartupError( @@ -167,9 +173,9 @@ export class ConversationService { let launchWorkDir = workDir let launchRepository = launchInfo?.repository - if (!shouldResume && launchRepository?.worktree) { + if (shouldCreateWorktree && launchRepository?.worktree) { launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir - } else if (!shouldResume && launchRepository) { + } else if (!shouldResume && launchRepository && !hasMaterializedWorktree) { const preparedWorkspace = await prepareSessionWorkspace( workDir, { @@ -182,6 +188,13 @@ export class ConversationService { launchRepository = preparedWorkspace.repository } + if (!shouldCreateWorktree && launchRepository?.worktree) { + launchRepository = { + ...launchRepository, + worktree: false, + } + } + if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) { throw new ConversationStartupError( `Working directory does not exist or is not a directory: ${launchWorkDir}`, diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index f4d9cc12..4d059da9 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -82,6 +82,39 @@ export type PreparedSessionWorkspace = { } } +export type RepositorySessionLaunchState = { + workDir: string + repository?: PreparedSessionWorkspace['repository'] + worktreeSession?: { worktreePath?: string | null } | null + transcriptMessageCount: number +} + +function samePath(left: string | null | undefined, right: string | null | undefined): boolean { + if (!left || !right) return false + return path.resolve(left) === path.resolve(right) +} + +export function isMaterializedWorktreeLaunch( + launchInfo: RepositorySessionLaunchState, +): boolean { + const worktreePath = launchInfo.repository?.worktreePath + return ( + samePath(launchInfo.workDir, worktreePath) || + samePath(launchInfo.workDir, launchInfo.worktreeSession?.worktreePath) || + samePath(worktreePath, launchInfo.worktreeSession?.worktreePath) + ) +} + +export function shouldCreateWorktreeForSessionLaunch( + launchInfo: RepositorySessionLaunchState, +): boolean { + return !!( + launchInfo.repository?.worktree && + launchInfo.transcriptMessageCount === 0 && + !isMaterializedWorktreeLaunch(launchInfo) + ) +} + type GitResult = { stdout: string stderr: string diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index ec3b0989..b053ca3e 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -855,18 +855,20 @@ export class SessionService { return [] } - const matches: Array<{ filePath: string; projectDir: string }> = [] + const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number }> = [] for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { - await fs.access(filePath) - matches.push({ filePath, projectDir: dir }) + const stat = await fs.stat(filePath) + matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs }) } catch { continue } } return matches + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ filePath, projectDir }) => ({ filePath, projectDir })) } async findSessionFile( diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 247f9cb3..0afa6c9a 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -24,6 +24,7 @@ import { LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, } from '../../constants/xml.js' +import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js' const settingsService = new SettingsService() const providerService = new ProviderService() @@ -80,7 +81,7 @@ async function sendRepositoryStartupStatus( const repository = launchInfo?.repository if (!repository) return - if (repository.worktree) { + if (shouldCreateWorktreeForSessionLaunch(launchInfo)) { sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' }) } }