Merge IM deleted-session recovery into main

The detached worktree fix has been verified on adapter and server lanes
and is now integrated into the local main branch without pushing.

Constraint: main already contains follow-up local commits beyond the worktree base
Confidence: high
Scope-risk: moderate
Tested: bun test common/__tests__/session-store.test.ts common/__tests__/ws-bridge.test.ts
Tested: bun test src/server/__tests__/websocket-handler.test.ts src/server/__tests__/sessions.test.ts --timeout 30000
Tested: cd adapters && bunx tsc --noEmit
Tested: bun run check:adapters
Tested: bun run check:server
Not-tested: bun run verify fails on pre-existing agent-utils coverage ratchet; changed-line coverage is 95.92% and affected lanes pass
Related: 1f62bcd
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 09:58:42 +08:00
commit c9cc5e68a6
8 changed files with 166 additions and 2 deletions

View File

@ -41,6 +41,41 @@ describe('SessionStore', () => {
expect(store.get('chat-1')).toBeNull()
})
it('deletes every chat entry bound to a sessionId', () => {
store.set('chat-1', 'uuid-shared', '/project-a')
store.set('chat-2', 'uuid-other', '/project-b')
store.set('chat-3', 'uuid-shared', '/project-c')
const removed = store.deleteBySessionId('uuid-shared')
expect(removed.sort()).toEqual(['chat-1', 'chat-3'])
expect(store.get('chat-1')).toBeNull()
expect(store.get('chat-3')).toBeNull()
expect(store.get('chat-2')!.sessionId).toBe('uuid-other')
const reloaded = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(reloaded.get('chat-1')).toBeNull()
expect(reloaded.get('chat-3')).toBeNull()
expect(reloaded.get('chat-2')!.sessionId).toBe('uuid-other')
})
it('refreshes from disk before reading so running adapters do not reuse deleted mappings', () => {
store.set('chat-1', 'uuid-stale', '/project')
const serverSideStore = new SessionStore(path.join(tmpDir, 'sessions.json'))
expect(serverSideStore.deleteBySessionId('uuid-stale')).toEqual(['chat-1'])
expect(store.get('chat-1')).toBeNull()
expect(store.listAll()).toEqual([])
})
it('returns an empty list when deleting an unknown sessionId', () => {
store.set('chat-1', 'uuid-aaa', '/project')
expect(store.deleteBySessionId('uuid-missing')).toEqual([])
expect(store.get('chat-1')!.sessionId).toBe('uuid-aaa')
})
it('persists to disk and reloads', () => {
store.set('chat-1', 'uuid-aaa', '/path')

View File

@ -163,6 +163,24 @@ describe('WsBridge: handler serialization', () => {
bridge.destroy()
})
it('forgets a chat when the server closes the session normally', async () => {
const bridge = new WsBridge(serverUrl, 'test')
bridge.onServerMessage('chat-deleted', () => {})
bridge.connectSession('chat-deleted', 'sess-deleted')
await bridge.waitForOpen('chat-deleted')
const serverWs = connections[0]!
serverWs.close(1000, 'session deleted')
await new Promise((resolve) => setTimeout(resolve, 50))
expect(bridge.hasSession('chat-deleted')).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 1_100))
expect(connections).toHaveLength(1)
bridge.destroy()
})
it('resetSession clears the handler chain', async () => {
const bridge = new WsBridge(serverUrl, 'test')
bridge.onServerMessage('chat-reset', () => {})

View File

@ -25,23 +25,45 @@ export class SessionStore {
}
get(chatId: string): SessionEntry | null {
this.refresh()
return this.data[chatId] ?? null
}
set(chatId: string, sessionId: string, workDir: string): void {
this.refresh()
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
this.save()
}
delete(chatId: string): void {
this.refresh()
delete this.data[chatId]
this.save()
}
deleteBySessionId(sessionId: string): string[] {
this.refresh()
const removed: string[] = []
for (const [chatId, entry] of Object.entries(this.data)) {
if (entry.sessionId !== sessionId) continue
delete this.data[chatId]
removed.push(chatId)
}
if (removed.length > 0) {
this.save()
}
return removed
}
listAll(): Array<{ chatId: string } & SessionEntry> {
this.refresh()
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
}
private refresh(): void {
this.data = this.load()
}
private load(): StoreData {
try {
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))

View File

@ -201,7 +201,14 @@ export class WsBridge {
ws.on('close', (code, reason) => {
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
if (code === 1000) return
if (this.sessions.get(chatId) !== session) return
if (code === 1000) {
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
this.sessions.delete(chatId)
this.handlers.delete(chatId)
this.handlerChains.delete(chatId)
return
}
this.scheduleReconnect(chatId, sessionId)
})

View File

@ -1364,6 +1364,43 @@ describe('Sessions API', () => {
expect(res2.status).toBe(404)
})
it('DELETE /api/sessions/:id should remove matching IM adapter session mappings', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const otherSessionId = 'ffffffff-1111-2222-3333-ffffffffffff'
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])
await fs.writeFile(
path.join(tmpDir, 'adapter-sessions.json'),
JSON.stringify({
'wechat-chat': {
sessionId,
workDir: '/tmp/project-a',
updatedAt: 1,
},
'wechat-chat-2': {
sessionId,
workDir: '/tmp/project-b',
updatedAt: 2,
},
'other-chat': {
sessionId: otherSessionId,
workDir: '/tmp/project-c',
updatedAt: 3,
},
}, null, 2),
'utf-8',
)
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' })
expect(res.status).toBe(200)
const persisted = JSON.parse(
await fs.readFile(path.join(tmpDir, 'adapter-sessions.json'), 'utf-8'),
)
expect(persisted['wechat-chat']).toBeUndefined()
expect(persisted['wechat-chat-2']).toBeUndefined()
expect(persisted['other-chat'].sessionId).toBe(otherSessionId)
})
it('DELETE /api/sessions/:id should roll back the deleted marker when file deletion fails', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-api-test', sessionId, [makeSnapshotEntry()])

View File

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
import type { ServerWebSocket } from 'bun'
import {
__resetWebSocketHandlerStateForTests,
closeSessionConnection,
getActiveSessionIds,
handleWebSocket,
type WebSocketData,
@ -52,4 +53,20 @@ describe('WebSocket handler session isolation', () => {
expect(clearCallbacks).not.toHaveBeenCalled()
expect(cancelComputerUse).not.toHaveBeenCalled()
})
it('closes and removes an active client socket when a session is deleted', () => {
const sessionId = `delete-${crypto.randomUUID()}`
const ws = makeClientSocket(sessionId)
const clearCallbacks = spyOn(conversationService, 'clearOutputCallbacks')
const cancelComputerUse = spyOn(computerUseApprovalService, 'cancelSession')
handleWebSocket.open(ws)
expect(closeSessionConnection(sessionId, 'session deleted')).toBe(true)
expect(getActiveSessionIds()).not.toContain(sessionId)
expect(ws.close).toHaveBeenCalledWith(1000, 'session deleted')
expect(clearCallbacks).toHaveBeenCalledWith(sessionId)
expect(cancelComputerUse).toHaveBeenCalledWith(sessionId)
})
})

View File

@ -17,7 +17,7 @@
import { sessionService } from '../services/sessionService.js'
import { conversationService } from '../services/conversationService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { getSlashCommands } from '../ws/handler.js'
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
import { getCommandName } from '../../commands.js'
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
import { WorkspaceService } from '../services/workspaceService.js'
@ -32,6 +32,7 @@ import {
previewSessionRewind,
type RewindTargetSelector,
} from '../services/sessionRewindService.js'
import { SessionStore } from '../../../adapters/common/session-store.js'
const workspaceService = new WorkspaceService(
async (sessionId) => (
@ -353,9 +354,18 @@ async function deleteSession(sessionId: string): Promise<Response> {
conversationService.unmarkSessionDeleted(sessionId)
throw error
}
closeSessionConnection(sessionId, 'session deleted')
cleanupAdapterSessionMappings(sessionId)
return Response.json({ ok: true })
}
function cleanupAdapterSessionMappings(sessionId: string): void {
const removedChatIds = new SessionStore().deleteBySessionId(sessionId)
if (removedChatIds.length > 0) {
console.log(`[Sessions API] Removed ${removedChatIds.length} adapter session mapping(s) for ${sessionId}`)
}
}
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
const cachedCommands = getSlashCommands(sessionId)
if (cachedCommands.length > 0) {

View File

@ -1493,6 +1493,24 @@ export function sendToSession(sessionId: string, message: ServerMessage): boolea
return true
}
export function closeSessionConnection(sessionId: string, reason = 'session closed'): boolean {
const cleanupTimer = sessionCleanupTimers.get(sessionId)
if (cleanupTimer) {
clearTimeout(cleanupTimer)
sessionCleanupTimers.delete(sessionId)
}
computerUseApprovalService.cancelSession(sessionId)
conversationService.clearOutputCallbacks(sessionId)
cleanupSessionRuntimeState(sessionId)
const ws = activeSessions.get(sessionId)
if (!ws) return false
activeSessions.delete(sessionId)
ws.close(1000, reason)
return true
}
export function getActiveSessionIds(): string[] {
return Array.from(activeSessions.keys())
}