mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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')
|
||||
})
|
||||
|
||||
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, {
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user