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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 12:16:15 +08:00
parent 6e675f2d88
commit eea8ea5cbd
6 changed files with 100 additions and 6 deletions

View File

@ -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, {

View File

@ -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, [

View File

@ -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}`,

View File

@ -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

View File

@ -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(

View File

@ -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' })
}
}