diff --git a/src/server/index.ts b/src/server/index.ts index 7bbc52c9..8e772c92 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,6 +33,7 @@ import { createServerMessage } from '../shared/protocol.js' import { createContextStateMessage } from './ws/protocol.js' import { createWebSocketServer } from './ws/index.js' import { SessionManager } from './session/manager.js' +import { clearSessionsForDeletedProvider, reconcileSessionProviders } from './session/provider-reconcile.js' import { toClientSession } from './session/client-session.js' import { setRuntimeConfig } from './runtime-config.js' import { createSkillRoutes } from './routes/skills.js' @@ -109,6 +110,13 @@ export async function createServerHandle(config: Config): Promise // Create Provider Manager (handles LLM client lifecycle) const providerManager = createProviderManager(config, { adapters: providerAdapters }) + // Repair sessions still pinned to a provider that is gone (deleted before the delete + // cascade existed, or dropped from a hand-edited config). + const repairedSessions = reconcileSessionProviders(providerManager.getProviders().map((p) => p.id)) + if (repairedSessions > 0) { + logger.warn('Cleared unknown provider from sessions', { sessions: repairedSessions }) + } + // Create SessionManager instance (not singleton!) const sessionManager = new SessionManager(providerManager) @@ -2318,6 +2326,12 @@ export async function createServerHandle(config: Config): Promise providerManager.setProviders(updatedConfig.providers, updatedConfig.defaultModelSelection ?? undefined) config.defaultModelSelection = updatedConfig.defaultModelSelection + // Sessions pinned to this provider would keep an id that no longer resolves. + const clearedSessions = clearSessionsForDeletedProvider(id) + if (clearedSessions > 0) { + logger.info('Cleared provider from sessions of deleted provider', { providerId: id, sessions: clearedSessions }) + } + res.json({ success: true }) }) diff --git a/src/server/session/manager.ts b/src/server/session/manager.ts index 14015cae..737bf968 100644 --- a/src/server/session/manager.ts +++ b/src/server/session/manager.ts @@ -122,6 +122,9 @@ export class SessionManager { private dynamicContextChangedStore = new Map() private debugDumpStore = new Map() private warmedUpSessions = new Set() + // Sessions already warned about an unresolvable provider — getContextState runs on every + // turn, and the warning is only worth one line per session. + private unknownProviderWarned = new Set() private switchLocks = new Map>() private workspaceCreationLocks = new Map>() @@ -431,6 +434,7 @@ export class SessionManager { // Clean up warmup state this.warmedUpSessions.delete(id) + this.unknownProviderWarned.delete(id) // Delete session from DB dbDeleteSession(id) @@ -573,6 +577,8 @@ export class SessionManager { logger.debug('Setting session provider', { sessionId, providerId, providerModel }) updateSessionProvider(sessionId, providerId, providerModel) + // The pin changed, so a later unresolvable one is worth warning about again. + this.unknownProviderWarned.delete(sessionId) const updatedSession = this.requireSession(sessionId) this.emit({ type: 'session_updated', session: updatedSession }) @@ -1370,6 +1376,15 @@ export class SessionManager { } maxTokens = modelConfig?.contextWindow ?? providerManager.getCurrentModelContext() } else { + // The pinned provider is gone: the context window below is the global one, not the + // one this session was configured with, so the reported budget is a guess. + if (!this.unknownProviderWarned.has(sessionId)) { + this.unknownProviderWarned.add(sessionId) + logger.warn('Session references an unknown provider, falling back to the global context window', { + sessionId, + providerId: session.providerId, + }) + } maxTokens = providerManager.getCurrentModelContext() } } else { diff --git a/src/server/session/provider-reconcile.test.ts b/src/server/session/provider-reconcile.test.ts new file mode 100644 index 00000000..d0fd7df6 --- /dev/null +++ b/src/server/session/provider-reconcile.test.ts @@ -0,0 +1,67 @@ +/** + * Session Provider Reconciliation Tests + * + * A deleted provider used to leave its sessions pinned to an id that no longer resolves. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { closeDatabase, getDatabase, initDatabase } from '../db/index.js' +import { loadConfig } from '../config.js' +import { createProject } from '../db/projects.js' +import { initEventStore } from '../events/index.js' +import { createSession, getSession } from '../db/sessions.js' +import { clearSessionsForDeletedProvider, reconcileSessionProviders } from './provider-reconcile.js' + +describe('session provider reconciliation', () => { + let projectId: string + + beforeEach(() => { + closeDatabase() + const config = loadConfig() + config.database.path = ':memory:' + initDatabase(config) + initEventStore(getDatabase()) + + projectId = createProject('Test', '/tmp/test').id + }) + + afterEach(() => { + closeDatabase() + }) + + it('clears the sessions that referenced a deleted provider', () => { + const orphaned = createSession(projectId, '/tmp/test', 'orphaned', 'deleted-provider', 'some-model') + const alsoOrphaned = createSession(projectId, '/tmp/test', 'also orphaned', 'deleted-provider', 'other-model') + const untouched = createSession(projectId, '/tmp/test', 'untouched', 'kept-provider', 'kept-model') + + expect(clearSessionsForDeletedProvider('deleted-provider')).toBe(2) + + expect(getSession(orphaned.id)?.providerId).toBeNull() + expect(getSession(orphaned.id)?.providerModel).toBeNull() + expect(getSession(alsoOrphaned.id)?.providerId).toBeNull() + expect(getSession(untouched.id)?.providerId).toBe('kept-provider') + expect(getSession(untouched.id)?.providerModel).toBe('kept-model') + }) + + it('clears a provider that is not configured and leaves a configured one alone', () => { + const dead = createSession(projectId, '/tmp/test', 'dead', 'gone-provider', 'gone-model') + const live = createSession(projectId, '/tmp/test', 'live', 'live-provider', 'live-model') + const unpinned = createSession(projectId, '/tmp/test', 'unpinned') + + expect(reconcileSessionProviders(['live-provider'])).toBe(1) + + expect(getSession(dead.id)?.providerId).toBeNull() + expect(getSession(dead.id)?.providerModel).toBeNull() + expect(getSession(live.id)?.providerId).toBe('live-provider') + expect(getSession(live.id)?.providerModel).toBe('live-model') + expect(getSession(unpinned.id)?.providerId).toBeNull() + }) + + it('repairs nothing when every pinned provider is configured', () => { + const pinned = createSession(projectId, '/tmp/test', 'pinned', 'live-provider', 'live-model') + + expect(reconcileSessionProviders(['live-provider', 'another-provider'])).toBe(0) + + expect(getSession(pinned.id)?.providerId).toBe('live-provider') + }) +}) diff --git a/src/server/session/provider-reconcile.ts b/src/server/session/provider-reconcile.ts new file mode 100644 index 00000000..94e851b8 --- /dev/null +++ b/src/server/session/provider-reconcile.ts @@ -0,0 +1,42 @@ +/** + * Session Provider Reconciliation + * + * A session pins the provider it runs on by id. Nothing used to clear that pin when the + * provider was deleted, so the session kept an id that no longer resolves: the context + * window silently fell back to the global default (wrong denominator in the UI, which can + * trip auto-compaction) and the provider badge rendered as remote for a local provider. + * + * Clearing the pin puts the session back on the global provider, which is what an unpinned + * session already does. + */ + +import { listSessions, updateSessionProvider } from '../db/sessions.js' + +function clearSessionProviderWhere(isDangling: (providerId: string) => boolean): number { + let cleared = 0 + for (const session of listSessions()) { + if (session.providerId && isDangling(session.providerId)) { + updateSessionProvider(session.id, null, null) + cleared++ + } + } + return cleared +} + +/** + * Clear the provider pin of every session that referenced a provider being deleted. + * Returns how many sessions were cleared. + */ +export function clearSessionsForDeletedProvider(providerId: string): number { + return clearSessionProviderWhere((pinned) => pinned === providerId) +} + +/** + * Clear provider pins that no longer match a configured provider. Runs at startup to repair + * sessions orphaned before the delete cascade existed, or by a hand-edited config. + * Returns how many sessions were repaired. + */ +export function reconcileSessionProviders(configuredProviderIds: readonly string[]): number { + const configured = new Set(configuredProviderIds) + return clearSessionProviderWhere((pinned) => !configured.has(pinned)) +}