mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-11 04:44:29 +00:00
136 lines
4.7 KiB
TypeScript
136 lines
4.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}
|
||
|
|
}
|