mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
6e675f2d88
commit
eea8ea5cbd
@ -863,6 +863,31 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
expect(statusVerbs).toContain('Creating worktree')
|
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 () => {
|
it('keeps the default startup status for current-worktree repository sessions', async () => {
|
||||||
const repoDir = await createCleanGitRepo()
|
const repoDir = await createCleanGitRepo()
|
||||||
const { sessionId } = await sessionService.createSession(repoDir, {
|
const { sessionId } = await sessionService.createSession(repoDir, {
|
||||||
|
|||||||
@ -911,6 +911,26 @@ describe('SessionService', () => {
|
|||||||
expect(workDir).toBe('/tmp/latest-worktree')
|
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 () => {
|
it('should recover CLI worktree state from transcript metadata', async () => {
|
||||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [
|
await writeSessionFile('-tmp-project--claude-worktrees-desktop-main-12345678', sessionId, [
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import { ProviderService } from './providerService.js'
|
|||||||
import { sessionService } from './sessionService.js'
|
import { sessionService } from './sessionService.js'
|
||||||
import { diagnosticsService } from './diagnosticsService.js'
|
import { diagnosticsService } from './diagnosticsService.js'
|
||||||
import {
|
import {
|
||||||
|
isMaterializedWorktreeLaunch,
|
||||||
prepareSessionWorkspace,
|
prepareSessionWorkspace,
|
||||||
|
shouldCreateWorktreeForSessionLaunch,
|
||||||
type PreparedSessionWorkspace,
|
type PreparedSessionWorkspace,
|
||||||
} from './repositoryLaunchService.js'
|
} from './repositoryLaunchService.js'
|
||||||
import {
|
import {
|
||||||
@ -146,6 +148,10 @@ export class ConversationService {
|
|||||||
const shouldResume = !!launchInfo && launchInfo.transcriptMessageCount > 0
|
const shouldResume = !!launchInfo && launchInfo.transcriptMessageCount > 0
|
||||||
const shouldReplacePlaceholder =
|
const shouldReplacePlaceholder =
|
||||||
!!launchInfo && launchInfo.transcriptMessageCount === 0
|
!!launchInfo && launchInfo.transcriptMessageCount === 0
|
||||||
|
const shouldCreateWorktree =
|
||||||
|
!!launchInfo && shouldCreateWorktreeForSessionLaunch(launchInfo)
|
||||||
|
const hasMaterializedWorktree =
|
||||||
|
!!launchInfo && isMaterializedWorktreeLaunch(launchInfo)
|
||||||
|
|
||||||
if (this.deletedSessions.has(sessionId)) {
|
if (this.deletedSessions.has(sessionId)) {
|
||||||
throw new ConversationStartupError(
|
throw new ConversationStartupError(
|
||||||
@ -167,9 +173,9 @@ export class ConversationService {
|
|||||||
|
|
||||||
let launchWorkDir = workDir
|
let launchWorkDir = workDir
|
||||||
let launchRepository = launchInfo?.repository
|
let launchRepository = launchInfo?.repository
|
||||||
if (!shouldResume && launchRepository?.worktree) {
|
if (shouldCreateWorktree && launchRepository?.worktree) {
|
||||||
launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir
|
launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir
|
||||||
} else if (!shouldResume && launchRepository) {
|
} else if (!shouldResume && launchRepository && !hasMaterializedWorktree) {
|
||||||
const preparedWorkspace = await prepareSessionWorkspace(
|
const preparedWorkspace = await prepareSessionWorkspace(
|
||||||
workDir,
|
workDir,
|
||||||
{
|
{
|
||||||
@ -182,6 +188,13 @@ export class ConversationService {
|
|||||||
launchRepository = preparedWorkspace.repository
|
launchRepository = preparedWorkspace.repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shouldCreateWorktree && launchRepository?.worktree) {
|
||||||
|
launchRepository = {
|
||||||
|
...launchRepository,
|
||||||
|
worktree: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) {
|
if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) {
|
||||||
throw new ConversationStartupError(
|
throw new ConversationStartupError(
|
||||||
`Working directory does not exist or is not a directory: ${launchWorkDir}`,
|
`Working directory does not exist or is not a directory: ${launchWorkDir}`,
|
||||||
|
|||||||
@ -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 = {
|
type GitResult = {
|
||||||
stdout: string
|
stdout: string
|
||||||
stderr: string
|
stderr: string
|
||||||
|
|||||||
@ -855,18 +855,20 @@ export class SessionService {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches: Array<{ filePath: string; projectDir: string }> = []
|
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number }> = []
|
||||||
for (const dir of projectDirs) {
|
for (const dir of projectDirs) {
|
||||||
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath)
|
const stat = await fs.stat(filePath)
|
||||||
matches.push({ filePath, projectDir: dir })
|
matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs })
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
return matches
|
||||||
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||||
|
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSessionFile(
|
async findSessionFile(
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
LOCAL_COMMAND_STDERR_TAG,
|
LOCAL_COMMAND_STDERR_TAG,
|
||||||
LOCAL_COMMAND_STDOUT_TAG,
|
LOCAL_COMMAND_STDOUT_TAG,
|
||||||
} from '../../constants/xml.js'
|
} from '../../constants/xml.js'
|
||||||
|
import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
|
||||||
|
|
||||||
const settingsService = new SettingsService()
|
const settingsService = new SettingsService()
|
||||||
const providerService = new ProviderService()
|
const providerService = new ProviderService()
|
||||||
@ -80,7 +81,7 @@ async function sendRepositoryStartupStatus(
|
|||||||
const repository = launchInfo?.repository
|
const repository = launchInfo?.repository
|
||||||
if (!repository) return
|
if (!repository) return
|
||||||
|
|
||||||
if (repository.worktree) {
|
if (shouldCreateWorktreeForSessionLaunch(launchInfo)) {
|
||||||
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' })
|
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user