Files
TencentDB-Agent-Memory/src/offload/session-registry.ts
T

136 lines
4.7 KiB
TypeScript
Raw Normal View History

/**
* SessionRegistry: Per-session OffloadStateManager routing.
*
* Maps sessionKey → { manager, lastAccessMs } with LRU eviction.
* Eliminates the global singleton stateManager — each session gets
* its own isolated OffloadStateManager + StorageContext.
*/
import { OffloadStateManager } from "./state-manager.js";
import { parseSessionKey } from "./storage.js";
/** Matches internal memory-pipeline sessions (e.g. memory-{taskId}-session-{ts}). */
const INTERNAL_SESSION_RE = /memory-.*-session-\d+/;
/** Returns true if the sessionKey belongs to an internal memory-pipeline session. */
function isInternalMemorySession(sessionKey: string): boolean {
return INTERNAL_SESSION_RE.test(sessionKey);
}
/** Per-session context entry held by the registry. */
export interface SessionCtx {
readonly sessionKey: string;
readonly manager: OffloadStateManager;
lastAccessMs: number;
}
/** Maximum number of cached sessions before LRU eviction kicks in. */
const MAX_CACHED_SESSIONS = 20;
/** Routes sessionKey → per-session OffloadStateManager with LRU eviction. */
export class SessionRegistry {
private _sessions = new Map<string, SessionCtx>();
private _dataRoot: string;
readonly _registryId = ++SessionRegistry._registryCounter;
private static _registryCounter = 0;
constructor(dataRoot: string) {
this._dataRoot = dataRoot;
}
/** Get the configured data root. */
get dataRoot(): string {
return this._dataRoot;
}
/**
* Get or create a per-session manager.
* First access will create a new OffloadStateManager, call init() + switchSession()
* to fully initialize storage paths and rebuild in-memory state from offload files.
*/
async resolve(sessionKey: string, realSessionId?: string): Promise<SessionCtx> {
let entry = this._sessions.get(sessionKey);
if (entry) {
entry.lastAccessMs = Date.now();
return entry;
}
// New session — create manager and fully initialize
const mgr = new OffloadStateManager();
const parsed = parseSessionKey(sessionKey);
if (parsed) {
const effectiveSessionId = realSessionId || parsed.sessionId;
await mgr.init(this._dataRoot, parsed.agentName, effectiveSessionId);
// switchSession rebuilds confirmedOffloadIds, deletedOffloadIds,
// processedToolCallIds from offload JSONL, registers sessionKey mapping,
// and resets session-level runtime state.
await mgr.switchSession(sessionKey, this._dataRoot, realSessionId);
} else {
// sessionKey doesn't match "agent:<name>:<id>" format.
// Use a sanitized sessionKey as both agentName and sessionId
// so ctx is always initialized (avoids "ctx not initialized" errors).
const fallbackName = sessionKey.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").slice(0, 64) || "unknown";
const fallbackSessionId = realSessionId || `fallback-${Date.now()}`;
await mgr.init(this._dataRoot, fallbackName, fallbackSessionId);
}
entry = { sessionKey, manager: mgr, lastAccessMs: Date.now() };
this._sessions.set(sessionKey, entry);
// LRU eviction
if (this._sessions.size > MAX_CACHED_SESSIONS) {
this._evictOldest();
}
return entry;
}
/**
* Resolve a session only if it is NOT an internal memory-pipeline session.
*
* Returns null for memory sessions (e.g. `memory-{taskId}-session-{ts}`),
* preventing unnecessary OffloadStateManager creation, disk I/O, and LRU
* cache slot pollution for sessions that should never run offload.
*
* Callers that need unconditional resolve (e.g. tests) can still use resolve().
*/
async resolveIfAllowed(sessionKey: string, realSessionId?: string): Promise<SessionCtx | null> {
if (isInternalMemorySession(sessionKey)) return null;
return this.resolve(sessionKey, realSessionId);
}
/** Look up an existing session (does not create). Updates lastAccessMs. */
get(sessionKey: string): SessionCtx | undefined {
const entry = this._sessions.get(sessionKey);
if (entry) entry.lastAccessMs = Date.now();
return entry;
}
/** Number of cached sessions. */
get size(): number {
return this._sessions.size;
}
/** Iterate over all session keys. */
keys(): IterableIterator<string> {
return this._sessions.keys();
}
/** Iterate over all session entries. */
values(): IterableIterator<SessionCtx> {
return this._sessions.values();
}
/** Evict the least-recently-accessed session. */
private _evictOldest(): void {
let oldestKey: string | null = null;
let oldestMs = Infinity;
for (const [key, entry] of this._sessions) {
if (entry.lastAccessMs < oldestMs) {
oldestMs = entry.lastAccessMs;
oldestKey = key;
}
}
if (oldestKey) this._sessions.delete(oldestKey);
}
}