Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,6 +110,13 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
// 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)

Expand Down Expand Up @@ -2318,6 +2326,12 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
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 })
}
Comment on lines +2329 to +2333

res.json({ success: true })
})

Expand Down
15 changes: 15 additions & 0 deletions src/server/session/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export class SessionManager {
private dynamicContextChangedStore = new Map<string, boolean>()
private debugDumpStore = new Map<string, { cachedPrompt: string; cachedTools: string[]; liveTools: string[] }>()
private warmedUpSessions = new Set<string>()
// 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<string>()
private switchLocks = new Map<string, Promise<unknown>>()
private workspaceCreationLocks = new Map<string, Promise<void>>()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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', {
Comment on lines +1379 to +1383
sessionId,
providerId: session.providerId,
})
}
maxTokens = providerManager.getCurrentModelContext()
}
} else {
Expand Down
67 changes: 67 additions & 0 deletions src/server/session/provider-reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
42 changes: 42 additions & 0 deletions src/server/session/provider-reconcile.ts
Original file line number Diff line number Diff line change
@@ -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))
}
Loading