fix: keep worktree session metadata after CLI init

Worktree sessions briefly showed the expected footer badge and then reverted because the CLI init path produced a second transcript under the actual worktree cwd. The desktop placeholder still held the repository launch metadata, but the startup cleanup removed that placeholder after writing metadata through a lookup that could target the stale source transcript. The runtime session also kept the launch cwd instead of the init cwd.

This moves session metadata writes to the transcript matching the CLI init cwd, carries repository launch metadata across duplicate session files, and updates the live ConversationService workDir when the init event reports the real cwd.

Constraint: Native CLI worktree creation happens inside the CLI after desktop has already created a placeholder session.

Rejected: Patch the footer to remember the first worktree badge | it would hide stale backend state and leave other session APIs on the source checkout.

Confidence: high

Scope-risk: narrow

Directive: Treat the CLI init cwd as authoritative for active desktop sessions; do not let placeholder cleanup discard repository launch metadata.

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern worktree|git-info|placeholder

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- src/components/shared/ProjectContextChip.test.tsx

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 18:24:16 +08:00
parent 3794a9d9ef
commit 5f0ddaecb2
4 changed files with 78 additions and 13 deletions

View File

@ -932,6 +932,45 @@ describe('SessionService', () => {
await expect(fs.access(worktreeFile)).resolves.toBeNull() await expect(fs.access(worktreeFile)).resolves.toBeNull()
}) })
it('should move repository metadata to the CLI worktree transcript before deleting placeholders', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const { sessionId } = await service.createSession(
workDir,
{ branch: 'main', worktree: true },
)
const initialLaunchInfo = await service.getSessionLaunchInfo(sessionId)
const worktreePath = initialLaunchInfo?.repository?.worktreePath
expect(worktreePath).toBeTruthy()
const worktreeFile = await writeSessionFile(sanitizePath(worktreePath!), sessionId, [
makeSnapshotEntry(),
{
type: 'system',
subtype: 'init',
cwd: worktreePath,
timestamp: '2026-01-01T00:00:01.000Z',
},
makeUserEntry('Hello from worktree'),
])
await service.appendSessionMetadata(sessionId, {
workDir: worktreePath!,
})
const removed = await service.deletePlaceholderSessionFiles(sessionId, worktreePath!)
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(removed).toBe(1)
await expect(fs.access(worktreeFile)).resolves.toBeNull()
expect(launchInfo?.workDir).toBe(worktreePath)
expect(launchInfo?.repository).toMatchObject({
requestedWorkDir: await fs.realpath(workDir),
branch: 'main',
worktree: true,
worktreePath,
worktreeSlug: initialLaunchInfo?.repository?.worktreeSlug,
})
})
it('should recover workDir from transcript cwd when session-meta is missing', async () => { it('should recover workDir from transcript cwd when session-meta is missing', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [ await writeSessionFile('-tmp-project', sessionId, [

View File

@ -520,6 +520,12 @@ export class ConversationService {
return session?.workDir || '' return session?.workDir || ''
} }
updateSessionWorkDir(sessionId: string, workDir: string): void {
const session = this.sessions.get(sessionId)
if (!session || !workDir.trim()) return
session.workDir = workDir
}
getSessionPermissionMode(sessionId: string): string { getSessionPermissionMode(sessionId: string): string {
const session = this.sessions.get(sessionId) const session = this.sessions.get(sessionId)
return session?.permissionMode || 'default' return session?.permissionMode || 'default'

View File

@ -803,12 +803,11 @@ export class SessionService {
* Find the .jsonl file for a given session ID. * Find the .jsonl file for a given session ID.
* Searches across all project directories since sessions may belong to any project. * Searches across all project directories since sessions may belong to any project.
*/ */
async findSessionFile( private async findSessionFiles(
sessionId: string sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> { ): Promise<Array<{ filePath: string; projectDir: string }>> {
// Validate sessionId format to prevent path traversal
if (!this.isValidSessionId(sessionId)) { if (!this.isValidSessionId(sessionId)) {
return null return []
} }
const projectsDir = this.getProjectsDir() const projectsDir = this.getProjectsDir()
@ -817,20 +816,27 @@ export class SessionService {
try { try {
projectDirs = await fs.readdir(projectsDir) projectDirs = await fs.readdir(projectsDir)
} catch { } catch {
return null return []
} }
const matches: Array<{ filePath: string; projectDir: string }> = []
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) await fs.access(filePath)
return { filePath, projectDir: dir } matches.push({ filePath, projectDir: dir })
} catch { } catch {
continue continue
} }
} }
return null return matches
}
async findSessionFile(
sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> {
return (await this.findSessionFiles(sessionId))[0] ?? null
} }
private isValidSessionId(id: string): boolean { private isValidSessionId(id: string): boolean {
@ -1460,12 +1466,25 @@ export class SessionService {
repository?: PreparedSessionWorkspace['repository'] repository?: PreparedSessionWorkspace['repository']
} }
): Promise<void> { ): Promise<void> {
const found = await this.findSessionFile(sessionId) const matches = await this.findSessionFiles(sessionId)
if (!found) return if (matches.length === 0) return
const entries = await this.readJsonlFile(found.filePath) let repository = metadata.repository
const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries) if (!repository) {
await this.appendJsonlEntry(found.filePath, { for (const match of matches) {
const candidate = this.resolveRepositoryFromEntries(await this.readJsonlFile(match.filePath))
if (candidate) {
repository = candidate
break
}
}
}
const targetProjectDir = this.sanitizePath(metadata.workDir)
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
await this.appendJsonlEntry(targetFilePath, {
type: 'session-meta', type: 'session-meta',
isMeta: true, isMeta: true,
workDir: metadata.workDir, workDir: metadata.workDir,
@ -1474,7 +1493,7 @@ export class SessionService {
}) })
if (metadata.customTitle) { if (metadata.customTitle) {
await this.appendJsonlEntry(found.filePath, { await this.appendJsonlEntry(targetFilePath, {
type: 'custom-title', type: 'custom-title',
customTitle: metadata.customTitle, customTitle: metadata.customTitle,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@ -775,6 +775,7 @@ function markPrewarmed(sessionId: string) {
function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return
if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) { if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) {
conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd)
void (async () => { void (async () => {
await sessionService.appendSessionMetadata(sessionId, { await sessionService.appendSessionMetadata(sessionId, {
workDir: cliMsg.cwd, workDir: cliMsg.cwd,