fix(server): keep history session timestamps stable (#951)

Use transcript activity timestamps for session list ordering instead of JSONL mtime, and skip duplicate runtime metadata appends.

Tested: bun test src/server/__tests__/sessions.test.ts --test-name-pattern "metadata-only writes|duplicate runtime metadata"
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun run check:server
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:38 +08:00
parent c4157a7e90
commit 7b2477f492
2 changed files with 189 additions and 11 deletions

View File

@ -551,7 +551,7 @@ describe('SessionService', () => {
expect(page2.sessions).toHaveLength(1)
})
it('should only scan the requested page when listing many sessions', async () => {
it('should scan summaries before pagination so metadata-only writes cannot skew order', async () => {
for (let i = 0; i < 12; i++) {
const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
const filePath = await writeSessionFile('-tmp-many-sessions', id, [
@ -576,7 +576,49 @@ describe('SessionService', () => {
expect(result.total).toBe(12)
expect(result.sessions).toHaveLength(3)
expect(scanCount).toBe(3)
expect(scanCount).toBe(12)
})
it('should ignore metadata-only writes when sorting and dating the session list', async () => {
const activeSessionId = '10000000-aaaa-bbbb-cccc-eeeeeeeeeeee'
const viewedHistorySessionId = '10000001-aaaa-bbbb-cccc-eeeeeeeeeeee'
const activeFilePath = await writeSessionFile('-tmp-viewed-history-sessions', activeSessionId, [
makeSnapshotEntry(),
{
...makeUserEntry('Recent real work'),
timestamp: '2026-07-02T02:00:00.000Z',
},
{
...makeAssistantEntry('Recent reply'),
timestamp: '2026-07-02T02:05:00.000Z',
},
])
const historyFilePath = await writeSessionFile('-tmp-viewed-history-sessions', viewedHistorySessionId, [
makeSnapshotEntry(),
{
...makeUserEntry('Older work'),
timestamp: '2026-07-01T02:00:00.000Z',
},
{
...makeAssistantEntry('Older reply'),
timestamp: '2026-07-01T02:05:00.000Z',
},
{
...makeSessionMetaEntry('/tmp/viewed-history'),
timestamp: '2026-07-02T03:00:00.000Z',
},
])
await fs.utimes(activeFilePath, new Date('2026-07-02T02:05:00.000Z'), new Date('2026-07-02T02:05:00.000Z'))
await fs.utimes(historyFilePath, new Date('2026-07-02T03:00:00.000Z'), new Date('2026-07-02T03:00:00.000Z'))
const result = await service.listSessions({ project: '/tmp/viewed-history-sessions', limit: 2 })
expect(result.sessions.map((session) => session.id)).toEqual([
activeSessionId,
viewedHistorySessionId,
])
expect(result.sessions.find((session) => session.id === viewedHistorySessionId)?.modifiedAt)
.toBe('2026-07-01T02:05:00.000Z')
})
it('should reuse cached list metadata for repeated requests', async () => {
@ -604,7 +646,7 @@ describe('SessionService', () => {
const second = await service.listSessions({ limit: 3, offset: 0 })
expect(first.sessions.map((session) => session.id)).toEqual(second.sessions.map((session) => session.id))
expect(scanCount).toBe(3)
expect(scanCount).toBe(5)
})
it('should reuse unchanged file summaries after the list response cache is cleared', async () => {
@ -1345,6 +1387,42 @@ describe('SessionService', () => {
expect(launchInfo?.permissionMode).toBe('plan')
})
it('should not append duplicate runtime metadata when it already matches', async () => {
const workDir = '/tmp/runtime-idempotent'
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const filePath = await writeSessionFile(sanitizePath(workDir), sessionId, [
makeSnapshotEntry(),
{
...makeSessionMetaEntry(workDir),
runtimeProviderId: 'provider-a',
runtimeModelId: 'model-a',
effortLevel: 'max',
},
makeUserEntry('Runtime metadata should stay stable'),
])
const before = await fs.readFile(filePath, 'utf-8')
await service.appendSessionMetadata(sessionId, {
workDir,
runtimeProviderId: 'provider-a',
runtimeModelId: 'model-a',
effortLevel: 'max',
})
expect(await fs.readFile(filePath, 'utf-8')).toBe(before)
await service.appendSessionMetadata(sessionId, {
workDir,
runtimeProviderId: 'provider-a',
runtimeModelId: 'model-b',
effortLevel: 'max',
})
const afterChange = await fs.readFile(filePath, 'utf-8')
expect(afterChange).not.toBe(before)
expect(afterChange).toContain('"runtimeModelId":"model-b"')
})
it('should remove stale placeholder files after native CLI worktree startup', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const sourceFile = await writeSessionFile('-tmp-source', sessionId, [

View File

@ -286,6 +286,7 @@ type PersistedWorktreeSession = {
type SessionListSummary = {
title: string
createdAt: string
modifiedAt: string
messageCount: number
workDir: string | null
permissionMode?: string
@ -369,6 +370,64 @@ export class SessionService {
}
}
private latestTimestamp(current: string | null, candidate: unknown): string | null {
if (typeof candidate !== 'string') return current
const candidateTime = Date.parse(candidate)
if (!Number.isFinite(candidateTime)) return current
if (!current) return candidate
const currentTime = Date.parse(current)
return !Number.isFinite(currentTime) || candidateTime > currentTime
? candidate
: current
}
private metadataMatchesLaunchInfo(
launchInfo: SessionLaunchInfo | null,
metadata: {
workDir: string
repository?: PreparedSessionWorkspace['repository']
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: string
},
): boolean {
if (!launchInfo) return false
if (normalizeDriveRootPathForPlatform(launchInfo.workDir) !== metadata.workDir) {
return false
}
if (
JSON.stringify(launchInfo.repository ?? null) !==
JSON.stringify(metadata.repository ?? null)
) {
return false
}
if (
metadata.permissionMode &&
VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) &&
launchInfo.permissionMode !== metadata.permissionMode
) {
return false
}
if (
metadata.runtimeProviderId !== undefined &&
launchInfo.runtimeProviderId !== metadata.runtimeProviderId
) {
return false
}
if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) {
return false
}
if (
metadata.effortLevel &&
VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) &&
launchInfo.effortLevel !== metadata.effortLevel
) {
return false
}
return true
}
private async getCachedSessionListSummary(
filePath: string,
projectDir: string,
@ -469,10 +528,11 @@ export class SessionService {
private async scanSessionListSummary(
filePath: string,
projectDir: string,
stat: { birthtime: Date },
stat: { birthtime: Date; mtime: Date },
): Promise<SessionListSummary> {
let createdAt = stat.birthtime.toISOString()
let hasCreatedAt = false
let modifiedAt: string | null = null
let messageCount = 0
let firstUserTitle: string | null = null
let goalTitle: string | null = null
@ -515,6 +575,9 @@ export class SessionService {
entry.message?.role
) {
messageCount += 1
if (!entry.isMeta) {
modifiedAt = this.latestTimestamp(modifiedAt, entry.timestamp)
}
}
if (entry.type === 'session-meta') {
@ -602,6 +665,7 @@ export class SessionService {
firstUserTitle ||
'Untitled Session',
createdAt,
modifiedAt: modifiedAt ?? stat.mtime.toISOString(),
messageCount,
workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir),
...(permissionMode ? { permissionMode } : {}),
@ -631,7 +695,7 @@ export class SessionService {
const summary = await this.scanSessionListSummary(filePath, projectPath, stat)
return {
title: summary.title,
modifiedAt: stat.mtime.toISOString(),
modifiedAt: summary.modifiedAt,
workDir: summary.workDir ?? null,
projectPath,
}
@ -2163,20 +2227,44 @@ export class SessionService {
}
}))).filter((item): item is NonNullable<typeof item> => item !== null)
filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())
const summarizedFiles: Array<{
filePath: string
projectDir: string
sessionId: string
stat: Stats
summary: SessionListSummary
}> = []
for (const item of filesWithStats) {
try {
summarizedFiles.push({
...item,
summary: await this.getCachedSessionListSummary(
item.filePath,
item.projectDir,
item.stat,
),
})
} catch {
// Skip unreadable files
}
}
const total = filesWithStats.length
summarizedFiles.sort(
(a, b) =>
Date.parse(b.summary.modifiedAt) - Date.parse(a.summary.modifiedAt),
)
const total = summarizedFiles.length
const offset = options?.offset ?? 0
const limit = options?.limit ?? 50
const paginatedFiles = filesWithStats.slice(offset, offset + limit)
const paginatedFiles = summarizedFiles.slice(offset, offset + limit)
// Build session list items with metadata from file stats & a streaming
// transcript summary. Keep this sequential so large JSONL files are not
// loaded into memory concurrently by the sidebar's frequent refresh.
const items: SessionListItem[] = []
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
for (const { projectDir, sessionId, summary } of paginatedFiles) {
try {
const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat)
const workDir = summary.workDir
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
worktreeSession: summary.worktreeSession,
@ -2190,7 +2278,7 @@ export class SessionService {
id: sessionId,
title: summary.title,
createdAt: summary.createdAt,
modifiedAt: stat.mtime.toISOString(),
modifiedAt: summary.modifiedAt,
messageCount: summary.messageCount,
projectPath: projectDir,
projectRoot,
@ -2604,6 +2692,18 @@ export class SessionService {
const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir)
const targetProjectDir = this.sanitizePath(normalizedWorkDir)
const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`)
if (!metadata.customTitle) {
const launchInfo = await this.getSessionLaunchInfo(sessionId)
if (this.metadataMatchesLaunchInfo(launchInfo, {
...metadata,
workDir: normalizedWorkDir,
repository,
})) {
return
}
}
await fs.mkdir(path.dirname(targetFilePath), { recursive: true })
await this.appendJsonlEntry(targetFilePath, {