diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 9a5b1d3d..73035777 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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`, { diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index b7a13189..d1dc71ef 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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() + // 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() private readonly privateTitles = new Map>() 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 { 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 { 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 ? {