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()
})
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, [

View File

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

View File

@ -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<Array<{ filePath: string; projectDir: string }>> {
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<void> {
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(),

View File

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