merge: integrate QA-003 active-session lifecycle fix

This commit is contained in:
程序员阿江(Relakkes)
2026-09-10 19:08:48 +08:00
2 changed files with 64 additions and 2 deletions
+53
View File
@@ -3474,6 +3474,59 @@ describe('Sessions API', () => {
expect((await fetch(`${baseUrl}/api/sessions/${crypto.randomUUID()}/turn-checkpoints`)).status).toBe(404)
})
for (const origin of ['created', 'resumed']) {
it(`GET turn-checkpoints keeps a positive ${origin} session known immediately after retention cleanup`, async () => {
const { sessionService } = await import('../services/sessionService.js')
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 365 }))
resetSettingsCache()
let sessionId: string
if (origin === 'created') {
const created = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: tmpDir }),
})
expect(created.status).toBe(201)
sessionId = (await created.json() as { sessionId: string }).sessionId
} else {
sessionId = crypto.randomUUID()
await writeSessionFile(sanitizePath(tmpDir), sessionId, [
{ type: 'session-meta', workDir: tmpDir },
{ type: 'user', uuid: 'restored-user', parentUuid: null, message: { role: 'user', content: 'saved before server restart' } },
])
// Starting the runtime for an existing disk session updates metadata;
// this ID was never created by the current SessionService process.
await sessionService.appendSessionMetadata(sessionId, { workDir: tmpDir })
}
const found = await sessionService.findSessionFile(sessionId)
expect(found).not.toBeNull()
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 }))
resetSettingsCache()
await fs.unlink(found!.filePath)
// Clicking the still-open tab requests checkpoints before another prompt
// can refresh runtime metadata. No disk evidence must be created here.
for (const days of [0, 365]) {
await fs.writeFile(path.join(tmpDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: days }))
resetSettingsCache()
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ checkpoints: [] })
expect(await sessionService.getSessionMessagesWithEvidence(sessionId)).toEqual({
messages: [], transcriptEvidenceComplete: false,
})
expect(await sessionService.findSessionFile(sessionId)).toBeNull()
expect((await fetch(`${baseUrl}/api/sessions/${crypto.randomUUID()}/turn-checkpoints`)).status).toBe(404)
}
// Explicit deletion ends this in-process lifetime even when cleanup has
// already removed its file; it must not become an immortal empty session.
await sessionService.deleteSession(sessionId)
expect((await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)).status).toBe(404)
await expect(sessionService.deleteSession(sessionId)).rejects.toMatchObject({ statusCode: 404 })
})
}
it('POST /api/sessions should create a session', async () => {
const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-'))
const res = await fetch(`${baseUrl}/api/sessions`, {
+11 -2
View File
@@ -588,6 +588,10 @@ export class SessionService {
// Keep launch state available when retention disables or removes transcripts.
// Scope keys by config directory so test/embedded server instances cannot mix state.
private readonly memoryLaunchInfo = new Map<string, SessionLaunchInfo>()
// Creation or a valid runtime metadata update establishes a session lifetime
// independently of its transcript. Cleanup can remove that file before the
// next runtime update supplies memoryLaunchInfo.
private readonly knownSessionKeys = new Set<string>()
private readonly privateTitles = new Map<string, Set<string>>()
shouldPersistSession(): boolean {
@@ -3764,7 +3768,8 @@ export class SessionService {
// Retention-zero sessions intentionally have no transcript. The desktop
// still asks for turn checkpoints after each live reply; lack of saved
// evidence is not a missing session, nor proof of an empty history.
if (this.memoryLaunchInfo.has(this.memorySessionKey(sessionId))) {
if (this.memoryLaunchInfo.has(this.memorySessionKey(sessionId)) ||
this.knownSessionKeys.has(this.memorySessionKey(sessionId))) {
return { messages: [], transcriptEvidenceComplete: false }
}
throw ApiError.notFound(`Session not found: ${sessionId}`)
@@ -4071,6 +4076,7 @@ export class SessionService {
if (persist && this.shouldPersistSession()) {
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
}
this.knownSessionKeys.add(this.memorySessionKey(sessionId))
this.invalidateSessionListCache()
return { sessionId, workDir: absWorkDir }
@@ -4081,11 +4087,13 @@ export class SessionService {
*/
async deleteSession(sessionId: string): Promise<void> {
const found = await this.findSessionFile(sessionId)
if (!found && !this.memoryLaunchInfo.has(this.memorySessionKey(sessionId))) {
if (!found && !this.memoryLaunchInfo.has(this.memorySessionKey(sessionId)) &&
!this.knownSessionKeys.has(this.memorySessionKey(sessionId))) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
if (found) await fs.unlink(found.filePath)
this.knownSessionKeys.delete(this.memorySessionKey(sessionId))
this.memoryLaunchInfo.delete(this.memorySessionKey(sessionId))
this.privateTitles.delete(this.memorySessionKey(sessionId))
if (found) this.sessionListSummaryCache.delete(found.filePath)
@@ -4399,6 +4407,7 @@ export class SessionService {
): Promise<void> {
const persist = this.shouldPersistSession()
const storedInfo = await this.getSessionLaunchInfo(sessionId)
if (storedInfo) this.knownSessionKeys.add(this.memorySessionKey(sessionId))
const workDir = normalizeDriveRootPathForPlatform(metadata.workDir)
const projectDir = this.sanitizePath(workDir)
const previousInfo = storedInfo ?? (!persist ? {