From 5f0ddaecb296824fe981c2276debb9640d738504 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: Fri, 8 May 2026 18:24:16 +0800 Subject: [PATCH] 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 --- src/server/__tests__/sessions.test.ts | 39 +++++++++++++++++++ src/server/services/conversationService.ts | 6 +++ src/server/services/sessionService.ts | 45 +++++++++++++++------- src/server/ws/handler.ts | 1 + 4 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index faf89d71..cfed5ae9 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -932,6 +932,45 @@ describe('SessionService', () => { 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 () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 97c1ab43..a511eec8 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -520,6 +520,12 @@ export class ConversationService { 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 { const session = this.sessions.get(sessionId) return session?.permissionMode || 'default' diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 64b55095..19052a1a 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -803,12 +803,11 @@ export class SessionService { * Find the .jsonl file for a given session ID. * Searches across all project directories since sessions may belong to any project. */ - async findSessionFile( + private async findSessionFiles( sessionId: string - ): Promise<{ filePath: string; projectDir: string } | null> { - // Validate sessionId format to prevent path traversal + ): Promise> { if (!this.isValidSessionId(sessionId)) { - return null + return [] } const projectsDir = this.getProjectsDir() @@ -817,20 +816,27 @@ export class SessionService { try { projectDirs = await fs.readdir(projectsDir) } catch { - return null + return [] } + const matches: Array<{ filePath: string; projectDir: string }> = [] for (const dir of projectDirs) { const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`) try { await fs.access(filePath) - return { filePath, projectDir: dir } + matches.push({ filePath, projectDir: dir }) } catch { 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 { @@ -1460,12 +1466,25 @@ export class SessionService { repository?: PreparedSessionWorkspace['repository'] } ): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return + const matches = await this.findSessionFiles(sessionId) + if (matches.length === 0) return - const entries = await this.readJsonlFile(found.filePath) - const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries) - await this.appendJsonlEntry(found.filePath, { + let repository = metadata.repository + if (!repository) { + 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', isMeta: true, workDir: metadata.workDir, @@ -1474,7 +1493,7 @@ export class SessionService { }) if (metadata.customTitle) { - await this.appendJsonlEntry(found.filePath, { + await this.appendJsonlEntry(targetFilePath, { type: 'custom-title', customTitle: metadata.customTitle, timestamp: new Date().toISOString(), diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e30a9135..247f9cb3 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -775,6 +775,7 @@ function markPrewarmed(sessionId: string) { function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) { + conversationService.updateSessionWorkDir(sessionId, cliMsg.cwd) void (async () => { await sessionService.appendSessionMetadata(sessionId, { workDir: cliMsg.cwd,