Task 75 — DI lifetime fixes: captive dependency and AgentCore singleton
Phase: 5
Initiative: 45
Status: done
Owner: —
Slug: di-lifetime-fixes
Goal
Два DI-бага ставят под угрозу correctness и HA:
-
Captive dependency (H6):
LayeredMemoryManagerзарегистрирован как singleton (Program.cs:232,WebApi/Program.cs:261), но инжектитIWorkingMemoryкоторый зарегистрирован как scoped. В WebAPI scoped-сервис резолвится один раз для синглтона и разделяется между всеми HTTP-запросами — per-request изоляция теряется, происходит cross-request memory contamination. -
AgentCore singleton с mutable state (H7):
AgentCoreзарегистрирован как singleton (Program.cs:526,WebApi/Program.cs:425) но хранит per-session mutable state:_transcript(List),_currentToolTrace,_lastInput,SessionId(AgentCore.cs:105-111,161). Все HTTP-запросы делят одинAgentCoreи один transcript.WebApiAdapter.EnsureSessionStarted()(:499) стартует одну сессию на весь процесс.
Acceptance criteria
Sub-tasks
- H6 fix — LayeredMemoryManager: сделать
LayeredMemoryManagerscoped (per-request в WebAPI, per-session в CLI). Проверить что все его зависимости (IWorkingMemory, fact/episode stores) тоже scoped или singleton-safe. - Альтернатива (если
LayeredMemoryManagerдолжен быть singleton по perf-причинам): сделатьIWorkingMemorysingleton и добавить per-request context черезAsyncLocal<WorkingMemoryContext>или externalize state вISessionStorekeyed bySessionId. Документировать выбор.- Решение: externalize via
ISessionStateStore.LayeredMemoryManagerостаётся singleton, но резолвит per-sessionIWorkingMemoryчерезISessionStateStore.GetOrCreate(sessionId).WorkingMemory. Никаких captive-dep.
- Решение: externalize via
- H7 fix — AgentCore: preferred approach — externalize session state. Создать
ISessionStateStore(in-memoryConcurrentDictionary<string, SessionState>+ optional SQLite):GetOrCreate(sessionId)возвращаетSessionState { Transcript, CurrentToolTrace, LastInput, SessionId }.AgentCoreостаётся singleton, но читает/пишет state через store keyed bySessionId. Agent/AgentCore.cs— убрать поля_transcript,_currentToolTrace,_lastInput,SessionId; заменить на_sessionState.GetOrCreate(sessionId)в началеHandleAsyncCore.Agent/AgentCore.cs:169—Transcriptgetter:=> _sessionState.GetOrCreate(SessionId).Transcript.ToList()(или убрать getter если не используется externally).Agent/AgentCore.cs:486,498,1049— все обращения к_transcript.Addзаменить на_sessionState.GetOrCreate(sessionId).Transcript.Addпод локомSessionState.TranscriptLock.Agent/WebApiAdapter.cs:499—EnsureSessionStarted()должен создавать/восстанавливать session per-request (или perX-Session-Idheader), не один на процесс.- Создать
Agent/ISessionStateStore.cs+Agent/SessionStateStore.cs(in-memory) + optionalAgent/SqliteSessionStateStore.cs.InMemorySessionStateStore.cs— singletonConcurrentDictionary+ LRU-eviction. SQLite вариант отложен (по заданию — optional).
Program.cs+WebApi/Program.cs— зарегистрироватьISessionStateStore(singleton — это store, не state).- Unit-тест: два параллельных
AgentCore.HandleAsyncс разнымиsessionIdне делят transcript. - Unit-тест: DI validation — resolve
LayeredMemoryManagerиз двух разных scopes даёт разные инстансы (если scoped) ИЛИIWorkingMemoryresolved per-scope (если split).- Реализовано как
LayeredMemoryManager_SessionScoped_WorkingMemoryIsolatedPerSession+InMemorySessionStateStore_PerSessionWorkingMemory_AreDifferentInstances.
- Реализовано как
dotnet build+dotnet testpass.
Scope / Likely files
src/agent/Agent/AgentCore.cs, src/agent/Agent/WebApiAdapter.cs, src/agent/Agent/ISessionStateStore.cs (new), src/agent/Agent/SessionStateStore.cs (new), src/agent/Memory/Layers/LayeredMemoryManager.cs, src/agent/Memory/IWorkingMemory.cs, src/agent/Memory/WorkingMemoryService.cs, src/agent/Program.cs, src/agent/Hercules.WebApi/Program.cs
Dependencies
- блокирует / опирается на: task_001 — core-agent-loop
- блокирует / опирается на: task_011 — layered-memory
- блокирует / опирается на: task_005 — interfaces
Risks / Rollback
Externalization session state — большой refactor AgentCore. CLI single-session model должен сохраниться (один sessionId на процесс). Rollback: вернуть singleton AgentCore с mutable state (но баг останется). Тесты WebAPI с параллельными запросами — must-have.
Implementation notes
2026-08-14
Добавлено:
src/agent/Agent/SessionState.cs (new) — per-session mutable state, externalised from AgentCore:
SessionId(immutable)Transcript(List+ `TranscriptLock` для thread-safe Append/TakeLast) ToolTrace(List+ `ToolTraceLock`) LastInput(string) — для порога создания навыкаContextBlock(string) — кешированный контекст памяти (legacyMemoryManager.BuildContextBlock)CommandCount(int) — для reflection cadenceWorkingMemory(IWorkingMemoryper-session) — H6 fix: per-sessionIWorkingMemory, не shared singletonAppendTranscript,TakeLastTranscript,ClearTranscript,AppendToolTrace,ClearToolTrace
src/agent/Agent/ISessionStateStore.cs (new) — интерфейс:
GetOrCreate(sessionId) → SessionStateRemove(sessionId),ListSessions(),Count
src/agent/Agent/InMemorySessionStateStore.cs (new) — реализация на ConcurrentDictionary<string, SessionState>, LRU-eviction при MaxSessions (default 1024).
src/agent/Agent/AgentCore.cs — refactor:
- Удалены mutable-поля:
_transcript,_transcriptLock,_contextBlock,_lastInput,_currentToolTrace. - Добавлен
ISessionStateStore _sessionStatesdependency (optional ctor param с fallback наnew InMemorySessionStateStore()для backward compat). HandleAsync(input, options, sessionId?, externalCt)— multi-session overload.StartSession(string sessionId)— multi-session overload.BuildMessages,BuildResponse,RunWithToolsAsync— принимаютSessionStateparameter.- Все
SessionId-keyed обращения (guardrails, audit, escalation, log interaction) идут через параметрsessionId, не черезthis.SessionId. TranscriptиCommandCountgetters проксируют вGetState(SessionId).*.
src/agent/Agent/WebApiAdapter.cs:
EnsureSessionStarted(string sessionId)— multi-session overload.ChatAsync(message, sessionId, ct)— multi-session overload.DefaultSessionIdproperty — для ChatController.
src/agent/Hercules.WebApi/Controllers/ChatController.cs — multi-tenant режим:
POST /api/chatчитает optionalX-Session-Idheader.- Если header отсутствует — используется
DefaultSessionId(back-compat single-tenant). - Если header задан — вызывает
EnsureSessionStarted(sessionId)для per-request init.
src/agent/Memory/Layers/LayeredMemoryManager.cs — два ctor:
- Legacy
(IWorkingMemory, IDurableFactsStore, IEpisodicStore, config?)— для тестов / CLI single-tenant. - Новый
(ISessionStateStore, IDurableFactsStore, IEpisodicStore, config?)— H6 fix: per-sessionIWorkingMemoryresolved on demand. BuildContextBlockAsync(sessionId?, ct)— per-session working memory view.SetWorking/GetWorking/ClearWorking(key, sessionId?)— per-session.
src/agent/Agent/MemoryManager.cs — BuildContextBlock(string? sessionId) overload: пробрасывает sessionId в LayeredMemoryManager.BuildContextBlockAsync.
src/agent/Program.cs + src/agent/Hercules.WebApi/Program.cs — DI:
ISessionStateStoreregistered as singleton.LayeredMemoryManagerregistered as singleton с constructorISessionStateStore.- Removed
IWorkingMemoryregistration (теперь создаётся per-session вSessionState).
tests/Hercules.Agent.Tests/AgentCoreTests/DiLifetimeFixesTests.cs (new) — 11 unit-тестов:
HandleAsync_ParallelSessions_TranscriptsDoNotInterleave— H7 fix: parallel HandleAsync с разными sessionId.HandleAsync_DifferentSessions_IncrementSeparateCommandCount—state.CommandCountper-session.HandleAsync_SameSession_AccumulatesCommandCount— accumulation работает.SessionStateStore_GetOrCreate_ReturnsSameInstance— стабильность per-key.SessionStateStore_DifferentIds_ReturnDifferentInstances— изоляция.SessionStateStore_Remove_DeletesSession— cleanup.SessionState_Transcript_AppendIsolatedPerSession— per-session transcript.SessionState_ToolTrace_AppendIsolatedPerSession— per-session tool trace.LayeredMemoryManager_SessionScoped_WorkingMemoryIsolatedPerSession— H6 fix: per-session working memory.LayeredMemoryManager_LegacyCtor_StillWorksForBackCompat— back-compat ctor.InMemorySessionStateStore_PerSessionWorkingMemory_AreDifferentInstances—IWorkingMemoryper-session.
Validation:
dotnet buildHercules.csproj — 0 errors (warnings pre-existing)dotnet buildHercules.WebApi.csproj — 0 errors (1 pre-existing warning)dotnet test --filter "FullyQualifiedName~AgentCoreTests|FullyQualifiedName~Memory|FullyQualifiedName~Lifecycle"— 230/230 passed (101 AgentCore + 68 Memory + 50+ Lifecycle + 11 new DiLifetimeFixes)dotnet test(full) — 1779/1790 passed, 9 failed = pre-existing (Redis, OpenTelemetry, NumericValidator, BudgetGuard hard-cap). My changes не сломали ни одного passing test.
Back-compat guarantees:
- CLI single-tenant:
AgentCore.SessionIdостаётся,EnsureSessionStarted()(no-arg) стартует default session. MemoryManager.BuildContextBlock()(no-arg) — без sessionId, использует “default” working memory view.LayeredMemoryManagerlegacy ctor(IWorkingMemory, ...)— сохранён для существующих unit-тестов.AgentCorector —ISessionStateStoreопциональный (default =new InMemorySessionStateStore()).- WebApi:
POST /api/chatбезX-Session-Idheader работает как раньше.
Links
- Backlog: ../backlog.md
- Roadmap (EN): ../../ROADMAP-EN.md
- Roadmap (RU): ../../ROADMAP-RU.md