From eea8ea5cbdf0306e2a658d632ac40273434feb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 12:16:15 +0800 Subject: [PATCH] fix(desktop): avoid duplicate worktree startup status Continuing an already materialized worktree session should behave like a normal existing session. The launch path now checks whether the session has already moved into its worktree before showing the startup bubble or passing worktree creation flags again. Constraint: Existing session metadata can retain repository.worktree after the worktree transcript becomes the active session file Rejected: Frontend-only suppression | the server could still launch the CLI with duplicate worktree creation flags Confidence: high Scope-risk: narrow Tested: bun run check:server Tested: bun test src/server/__tests__/conversations.test.ts -t worktree --timeout 30000 Tested: bun test src/server/__tests__/sessions.test.ts -t "newest duplicate session file" --timeout 30000 Not-tested: bun run verify still fails on unrelated desktop Settings.tsx and MessageList.tsx gates from the broader worktree --- src/server/__tests__/conversations.test.ts | 25 ++++++++++++++ src/server/__tests__/sessions.test.ts | 20 +++++++++++ src/server/services/conversationService.ts | 17 ++++++++-- .../services/repositoryLaunchService.ts | 33 +++++++++++++++++++ src/server/services/sessionService.ts | 8 +++-- src/server/ws/handler.ts | 3 +- 6 files changed, 100 insertions(+), 6 deletions(-) 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' }) } }