Forráskód Böngészése

回退聊天会话自动清理逻辑,保留手动操作与附件修复

samien 1 hónapja
szülő
commit
65412d27d4

+ 1 - 5
src/client/stores/chat.ts

@@ -300,11 +300,7 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   socket.onEvent(event => {
-    if (event.type === 'gateway.ready') {
-      // List validation can finish before the event channel is attached.
-      scheduleSessionListRefresh()
-      return
-    }
+    if (event.type === 'gateway.ready') return
     const payload = record(event.payload)
     if (event.type === 'sessions.changed') {
       scheduleSessionListRefresh()

+ 0 - 1
src/server/app.ts

@@ -446,7 +446,6 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     chatCache,
     close: () => {
-      chatCache?.close()
       workspaceAssets.close()
       workspaceRuntime.close()
       workspaceNodes.close()

+ 0 - 70
src/server/chatCache.ts

@@ -531,13 +531,6 @@ export class ChatCacheStore {
     this.markListsStale(owner)
   }
 
-  validationCandidates(owner: string, profile: string): Array<{ profile: string; session_id: string; sync_revision: number }> {
-    // Newly created runtime sessions may not have been persisted by Hermes yet.
-    return this.db.prepare(`SELECT profile,session_id,sync_revision FROM chat_sessions
-      WHERE owner=? AND owned_at IS NOT NULL AND owned_at<? AND (?='' OR profile=?)`)
-      .all(owner, Date.now() - 60_000, profile, profile) as Array<{ profile: string; session_id: string; sync_revision: number }>
-  }
-
   deleteSession(owner: string, profile: string, sessionID: string): void {
     const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
       WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
@@ -944,66 +937,15 @@ export class ChatCacheStore {
 
 export class ChatCacheCoordinator {
   private reconciling = new Map<string, Promise<void>>()
-  private validations = new Map<string, number>()
-  private validating = new Set<string>()
-  private stopped = false
   onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
   constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
     readonly mode: ChatCacheMode = 'prefer-local') {}
 
-  close(): void { this.stopped = true }
-
-  private validateList(owner: string, profile: string, load: () => Promise<UpstreamResponse>): void {
-    const key = JSON.stringify([owner, profile])
-    if (this.stopped || this.validating.has(key) || Date.now() - (this.validations.get(key) ?? 0) < 30_000) return
-    const candidates = this.store.validationCandidates(owner, profile)
-    if (!candidates.length) return
-    this.validations.set(key, Date.now())
-    this.validating.add(key)
-    void (async () => {
-      const response = await load()
-      if (this.stopped) return
-      const payload = parseResponse(response)
-      const items = payload?.sessions ?? payload?.items
-      if (!Array.isArray(items)) return
-      const existing = new Set(items.flatMap(value => {
-        const session = object(value)
-        return session ? [`${profileOf(session, profile) || 'default'}\u0000${sessionIDOf(session)}`] : []
-      }))
-      // A filtered/paginated list cannot prove deletion. Confirm each absent
-      // candidate against its own profile; outages must retain local history.
-      for (const candidate of candidates) {
-        if (this.stopped) return
-        if (existing.has(`${candidate.profile}\u0000${candidate.session_id}`)) continue
-        try {
-          const detail = await this.upstream.request(`/api/sessions/${encodeURIComponent(candidate.session_id)}`, {
-            search: new URLSearchParams({ profile: candidate.profile }), cache: 'reload',
-          })
-          if (this.stopped) return
-          if (detail.status === 404 && this.store.ownsSession(owner, candidate.profile, candidate.session_id)
-            && this.store.revision(owner, candidate.profile, candidate.session_id) === candidate.sync_revision) {
-            this.store.deleteSession(owner, candidate.profile, candidate.session_id)
-            this.onSynchronized(owner, candidate.profile, candidate.session_id)
-          }
-        } catch { /* Retain the session and retry on a later list refresh. */ }
-      }
-    })().catch(() => {}).finally(() => this.validating.delete(key))
-  }
-
   async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
     load: () => Promise<UpstreamResponse>, ownedList = false,
     listPage: { offset?: number; limit?: number; archived?: string } = {}): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
-    if (kind === 'list' && ownedList) this.validateList(owner, profile, load)
-    const revision = sessionID && this.store.ownsSession(owner, profile, sessionID)
-      ? this.store.revision(owner, profile, sessionID) : undefined
     if (this.mode === 'upstream-only') {
       const upstreamResponse = await load()
-      if (sessionID && revision !== undefined && upstreamResponse.status === 404
-        && this.store.ownsSession(owner, profile, sessionID)
-        && this.store.revision(owner, profile, sessionID) === revision) {
-        this.store.deleteSession(owner, profile, sessionID)
-        this.onSynchronized(owner, profile, sessionID)
-      }
       return {
         response: kind === 'list' && ownedList
           ? this.store.mergeOwnedSessionsIntoList(owner, profile, upstreamResponse, listPage)
@@ -1030,12 +972,6 @@ export class ChatCacheCoordinator {
     try {
       const requestStartedAt = Date.now()
       const upstreamResponse = await load()
-      if (sessionID && revision !== undefined && upstreamResponse.status === 404
-        && this.store.ownsSession(owner, profile, sessionID)
-        && this.store.revision(owner, profile, sessionID) === revision) {
-        this.store.deleteSession(owner, profile, sessionID)
-        this.onSynchronized(owner, profile, sessionID)
-      }
       if (this.mode === 'prefer-local' && local && upstreamResponse.status >= 500) {
         return { response: projectedLocal!.response, source: 'local', state: 'stale' }
       }
@@ -1105,11 +1041,6 @@ export class ChatCacheCoordinator {
       const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
       const detailStartedAt = Date.now()
       const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
-      if (this.stopped) return
-      if (detail.status === 404 && this.store.revision(owner, profile, sessionID) === revision) {
-        this.store.deleteSession(owner, profile, sessionID)
-        this.onSynchronized(owner, profile, sessionID)
-      }
       if (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
       const detailPayload = parseResponse(detail) ?? {}
       const detailSummary = object(detailPayload.session) ?? detailPayload
@@ -1125,7 +1056,6 @@ export class ChatCacheCoordinator {
         const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
         const startedAt = Date.now()
         const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
-        if (this.stopped) return
         const payload = parseResponse(response)
         if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
         const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])

+ 0 - 8
src/server/routes.ts

@@ -758,15 +758,7 @@ async function cachedChatRead(
       return
     }
     const requestStartedAt = Date.now()
-    const revision = options.sessionID && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)
-      ? dependencies.chatCache.store.revision(user.id, options.profile, options.sessionID) : undefined
     const upstreamResponse = await load()
-    if (options.sessionID && revision !== undefined && upstreamResponse.status === 404
-      && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)
-      && dependencies.chatCache.store.revision(user.id, options.profile, options.sessionID) === revision) {
-      dependencies.chatCache.store.deleteSession(user.id, options.profile, options.sessionID)
-      dependencies.chatCache.onSynchronized(user.id, options.profile, options.sessionID)
-    }
     const response = options.kind === 'list' && options.ownedList
       ? dependencies.chatCache.store.mergeOwnedSessionsIntoList(
           user.id,

+ 23 - 57
tests/server/chatCache.test.ts

@@ -31,7 +31,7 @@ afterEach(() => {
 
 describe('durable source=web chat cache', () => {
   it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
-    'preserves newer session activity and cached files after a stale 404 in %s mode', async mode => {
+    'preserves sessions and cached files after repeated read 404s in %s mode', async mode => {
       const f = fixture()
       const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
       const sourcePath = '/tmp/final-report.png'
@@ -58,75 +58,41 @@ describe('durable source=web chat cache', () => {
 
         await coordinator.messages(owner, 'older', profile, sessionID, 1, 1,
           async () => response({ detail: 'Session not found' }, 404))
-        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
-        expect(changed).toHaveBeenCalledWith(owner, profile, sessionID)
-      } finally { coordinator.close(); f.store.close() }
+        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+        expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
+        expect(changed).not.toHaveBeenCalled()
+      } finally { f.store.close() }
     },
   )
 
-  it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
+  it('serves older cached sessions without probing or automatically cleaning them', async () => {
     const f = fixture()
-    const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
-    for (const id of ids) {
-      const scope = id === 'other-profile' ? 'another' : profile
-      f.store.recordCommand(owner, scope, id, 'session.create', { source: 'web' })
-      f.store.putSnapshot(owner, `detail:${id}`, 'detail', scope, id, response({ id, profile: scope, title: id }))
-    }
-    f.store.db.prepare('UPDATE chat_sessions SET owned_at=? WHERE session_id<>?').run(Date.now() - 120_000, 'new-session')
-    const request = vi.mocked(f.coordinator.upstream.request).mockImplementation(async path => {
-      if (path.endsWith('/archived')) return response({ id: 'archived', archived: true })
-      if (path.endsWith('/unavailable')) return response({}, 503)
-      return response({ detail: 'Session not found' }, 404)
-    })
-    const changed = vi.fn()
-    f.coordinator.onSynchronized = changed
-    const load = vi.fn(async () => response({ sessions: [], total: 0 }))
-    await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
-    await vi.waitFor(() => expect(f.store.ownsSession(owner, profile, 'missing')).toBe(false))
-    expect(f.store.ownsSession(owner, profile, 'archived')).toBe(true)
-    expect(f.store.ownsSession(owner, profile, 'unavailable')).toBe(true)
-    expect(f.store.ownsSession(owner, profile, 'new-session')).toBe(true)
-    expect(f.store.ownsSession(owner, 'another', 'other-profile')).toBe(true)
-    expect(request.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
-    expect(changed).toHaveBeenCalledWith(owner, profile, 'missing')
-    await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
-    expect(load).toHaveBeenCalledTimes(1)
-    f.store.close()
+    try {
+      f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
+      f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
+      f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
+      const load = vi.fn(async () => response({ sessions: [], total: 0 }))
+      vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({}, 404))
+      const result = await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
+      expect(result.source).toBe('local')
+      expect(JSON.parse(result.response.body.toString()).sessions).toContainEqual(expect.objectContaining({ id: sessionID }))
+      expect(load).not.toHaveBeenCalled()
+      expect(f.coordinator.upstream.request).not.toHaveBeenCalled()
+      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+    } finally { f.store.close() }
   })
 
-  it('removes a missing session when reconciliation receives a definitive 404', async () => {
+  it('retains a cached session when reconciliation receives a 404', async () => {
     const f = fixture()
     f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
     f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: 'ghost' }))
     vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({ detail: 'Session not found' }, 404))
-    await f.coordinator.reconcile(owner, profile, sessionID).catch(() => {})
-    expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
-    expect(f.store.localDetail(owner, profile, sessionID)).toBeUndefined()
+    await expect(f.coordinator.reconcile(owner, profile, sessionID)).rejects.toThrow('Unable to synchronize conversation metadata')
+    expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+    expect(f.store.localDetail(owner, profile, sessionID)).toBeDefined()
     f.store.close()
   })
 
-  it('keeps local chats during a list outage and ignores deletion probes superseded by a new turn', async () => {
-    const f = fixture()
-    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
-    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
-    f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
-    const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(response({ sessions: [] }))
-    vi.useFakeTimers({ toFake: ['Date'] })
-    try {
-      await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
-      await new Promise(resolve => setTimeout(resolve, 0))
-      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-      vi.setSystemTime(Date.now() + 31_000)
-      vi.mocked(f.coordinator.upstream.request).mockImplementation(async () => {
-        f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'new turn' })
-        return response({}, 404)
-      })
-      await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
-      await vi.waitFor(() => expect(f.coordinator.upstream.request).toHaveBeenCalled())
-      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-    } finally { vi.useRealTimers(); f.store.close() }
-  })
-
   it('keeps acknowledged flags across aliases and rejects a deleted session sync', () => {
     const f = fixture()
     f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })

+ 2 - 2
tests/server/chatCacheRoutes.test.ts

@@ -33,7 +33,7 @@ afterEach(() => {
 
 describe('source=web chat cache routes', () => {
   it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
-    'preserves newer session activity after a stale bypass 404 in %s mode', async chatCacheMode => {
+    'preserves sessions after repeated bypass 404s in %s mode', async chatCacheMode => {
       const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-stale-bypass-')); homes.push(home)
       let duringRead = () => {}
       const fetchImpl = vi.fn<typeof fetch>(async input => {
@@ -58,7 +58,7 @@ describe('source=web chat cache routes', () => {
       expect(store.ownsSession(owner, 'default', 'owned')).toBe(true)
       duringRead = () => {}
       await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(404)
-      expect(store.ownsSession(owner, 'default', 'owned')).toBe(false)
+      expect(store.ownsSession(owner, 'default', 'owned')).toBe(true)
     },
   )