From 03e34b035ed1cfe82815ba4292b5b057fdb5b5a2 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sat, 16 May 2026 22:54:08 +0000 Subject: [PATCH] fix(acms): harden hot/warm/cold tier service reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused conflicting _DEFAULT_* constants that conflicted with canonical defaults in context_tier_settings.py (which serves as the sole source of truth for budget/setting defaults). - Add _enforce_warm_capacity() to enforce max_decisions_warm limit on warm tier after cold→warm promotion and hot-budget-fallback restore, preventing silent over-capacity data accumulation. - Return deep copies (model_copy) from get_all_fragments() and get_hot_fragments() to prevent callers from mutating internal fragment state while the service holds its RLock under concurrent plan execution. - Rename _COLD_SUMMARY_MAX_CHARS → _default_summarisation_max_chars for consistent snake_case naming throughout the module. Fixes: PR #9663 --- .../application/services/context_tiers.py | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/src/cleveragents/application/services/context_tiers.py b/src/cleveragents/application/services/context_tiers.py index 4f6df059d..33407e69b 100644 --- a/src/cleveragents/application/services/context_tiers.py +++ b/src/cleveragents/application/services/context_tiers.py @@ -50,22 +50,10 @@ from cleveragents.infrastructure.events.types import EventType logger = structlog.get_logger(__name__) # --------------------------------------------------------------------------- -# Default budget when settings are not provided +# Default runtime policy values (sources of truth; settings module has full list) # --------------------------------------------------------------------------- -_DEFAULT_MAX_TOKENS_HOT = 16000 -_DEFAULT_MAX_DECISIONS_WARM = 100 -_DEFAULT_MAX_DECISIONS_COLD = 500 - -# --------------------------------------------------------------------------- -# Default runtime policy values -# --------------------------------------------------------------------------- - -_DEFAULT_PROMOTION_THRESHOLD = 5 -_DEFAULT_HOT_TTL_HOURS = 24 -_DEFAULT_WARM_TTL_HOURS = 24 -# Maximum content length kept after cold-tier summarisation -_COLD_SUMMARY_MAX_CHARS = 200 +_default_summarisation_max_chars: int = 200 class ContextTierService(TierRuntimeMixin, ScopedTierMixin): @@ -209,12 +197,12 @@ class ContextTierService(TierRuntimeMixin, ScopedTierMixin): fragments: list[TieredFragment] = [] for store in (self._hot, self._warm, self._cold): fragments.extend(store.values()) - return fragments + return [f.model_copy() for f in fragments] def get_hot_fragments(self) -> list[TieredFragment]: """Return all fragments currently in the hot tier.""" with self._lock: - return list(self._hot.values()) + return [f.model_copy() for f in self._hot.values()] # ------------------------------------------------------------------ # Actor views @@ -314,6 +302,8 @@ class ContextTierService(TierRuntimeMixin, ScopedTierMixin): from_tier=ContextTier.COLD, to_tier=ContextTier.WARM, ) + # Enforce warm-tier capacity after promotion. + self._enforce_warm_capacity() return promoted if fragment_id in self._warm: @@ -336,6 +326,7 @@ class ContextTierService(TierRuntimeMixin, ScopedTierMixin): update={"tier": ContextTier.WARM}, ) self._warm[fragment_id] = restored + self._enforce_warm_capacity() logger.warning( "tier.promotion_budget_fallback", fragment_id=fragment_id, @@ -450,6 +441,38 @@ class ContextTierService(TierRuntimeMixin, ScopedTierMixin): # validate_fragment_scope, store_with_scope_check, get_scoped_metrics) # are provided by ScopedTierMixin — see scoped_tiers.py. + def _enforce_warm_capacity(self) -> list[str]: + """Evict LRU warm-tier fragments when fragment capacity is exceeded. + + Called automatically by :meth:`promote` after placing a fragment into + the warm tier (either on direct cold→warm promotion or as a fallback + from hot-budget enforcement). Uses count-based eviction rather than + token-based, since the warm tier is an in-memory dict of full-text + fragments. + + Returns a list of evicted fragment IDs. + """ + max_warm = self._budget.max_decisions_warm + while len(self._warm) > max_warm and self._warm: + oldest_id = min( + self._warm.keys(), + key=lambda fid: self._warm[fid].last_accessed, + ) + del self._warm[oldest_id] + self._emit_tier_event( + EventType.TIER_EVICTED, + oldest_id, + from_tier=ContextTier.WARM, + to_tier=None, + ) + logger.info( + "tier.warm_capacity_evicted", + fragment_id=oldest_id, + warm_count=len(self._warm), + capacity=max_warm, + ) + return [] + # ------------------------------------------------------------------ # Budget # ------------------------------------------------------------------ @@ -506,8 +529,8 @@ class ContextTierService(TierRuntimeMixin, ScopedTierMixin): A production implementation would call an LLM summariser. """ content = fragment.content - if len(content) > _COLD_SUMMARY_MAX_CHARS: - content = content[:_COLD_SUMMARY_MAX_CHARS] + "..." + if len(content) > _default_summarisation_max_chars: + content = content[:_default_summarisation_max_chars] + "..." return fragment.model_copy(update={"content": content}) -- 2.52.0