5151_FAILURE_RATE_THRESHOLD = 0.20
5252_FAILURE_RATE_CHECK_INTERVAL = 5
5353_SESSION_DURATION_DEFAULT = 3600 # seconds
54+ _SESSION_SCOPED_ALERT_TYPES = frozenset ({
55+ AlertType .COST_BUDGET_SESSION ,
56+ AlertType .SESSION_DURATION ,
57+ })
5458
5559# Row cap for the startup queries that rebuild CooldownTracker/_failure_rate_fired
5660# from the alerts table (#592). Generous rather than exact: missing a stale row
@@ -198,17 +202,23 @@ class CooldownTracker:
198202
199203 def __init__ (self , cooldown_seconds : int = 60 ) -> None :
200204 self .cooldown_seconds = cooldown_seconds
201- self ._last_fired : dict [tuple [str , str ], datetime ] = {}
205+ self ._last_fired : dict [tuple [str , str , str ], datetime ] = {}
202206
203- def is_suppressed (self , agent_id : str | None , alert_type : AlertType ) -> bool :
204- key = (agent_id or "" , alert_type .value )
207+ def is_suppressed (
208+ self , agent_id : str | None , alert_type : AlertType , session_id : str | None = None ,
209+ ) -> bool :
210+ scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
211+ key = (agent_id or "" , alert_type .value , scope or "" )
205212 last = self ._last_fired .get (key )
206213 if last is None :
207214 return False
208215 return (utcnow () - last ).total_seconds () < self .cooldown_seconds
209216
210- def record (self , agent_id : str | None , alert_type : AlertType ) -> None :
211- key = (agent_id or "" , alert_type .value )
217+ def record (
218+ self , agent_id : str | None , alert_type : AlertType , session_id : str | None = None ,
219+ ) -> None :
220+ scope = session_id if alert_type in _SESSION_SCOPED_ALERT_TYPES else ""
221+ key = (agent_id or "" , alert_type .value , scope or "" )
212222 self ._last_fired [key ] = utcnow ()
213223
214224 def hydrate (self , alerts : list [Alert ]) -> None :
@@ -221,7 +231,8 @@ def hydrate(self, alerts: list[Alert]) -> None:
221231 for alert in alerts :
222232 if alert .suppressed :
223233 continue
224- key = (alert .agent_id or "" , alert .type .value )
234+ scope = alert .session_id if alert .type in _SESSION_SCOPED_ALERT_TYPES else ""
235+ key = (alert .agent_id or "" , alert .type .value , scope or "" )
225236 existing = self ._last_fired .get (key )
226237 if existing is None or alert .fired_at > existing :
227238 self ._last_fired [key ] = alert .fired_at
@@ -245,6 +256,7 @@ def __init__(self, db: StorageBackend, config: TjConfig) -> None:
245256 # threshold re-fired (5/20, 6/20, 7/20 …), turning a few incidents into a
246257 # cascade of near-identical alerts.
247258 self ._failure_rate_fired : set [str ] = set ()
259+ self ._session_limit_fired : set [tuple [str , str ]] = set ()
248260 self ._hydrate_from_db ()
249261
250262 def _hydrate_from_db (self ) -> None :
@@ -275,6 +287,9 @@ def _hydrate_from_db(self) -> None:
275287 since = utcnow () - timedelta (seconds = self .cooldown .cooldown_seconds )
276288 recent = self .db .get_alerts (AlertFilters (since = since , limit = _HYDRATION_LIMIT ))
277289 self .cooldown .hydrate (recent )
290+ for alert in recent :
291+ if alert .type in _SESSION_SCOPED_ALERT_TYPES and alert .session_id :
292+ self ._session_limit_fired .add ((alert .session_id , alert .type .value ))
278293
279294 fired = self .db .get_alerts (
280295 AlertFilters (type = AlertType .FAILURE_RATE , limit = _HYDRATION_LIMIT )
@@ -305,6 +320,18 @@ def evaluate_session_end(self, session: SessionRecord) -> None:
305320 self ._check_cost_budgets (session )
306321 self ._check_session_duration (session )
307322
323+ def evaluate_session_progress (self , session : SessionRecord ) -> None :
324+ """Evaluate limits for sessions that do not emit an end marker.
325+
326+ OTLP and transcript-based SDK integrations may emit only individual
327+ ``gen_ai.llm.call`` spans. Those sessions remain active, so waiting
328+ for ``evaluate_session_end`` would make session cost and duration
329+ alerts unreachable. Daily budgets stay end-triggered because they
330+ are not session-scoped.
331+ """
332+ self ._check_session_budget (session )
333+ self ._check_session_duration (session )
334+
308335 def fire (
309336 self ,
310337 alert_type : AlertType ,
@@ -517,7 +544,17 @@ def _check_cost_budgets(self, session: SessionRecord) -> None:
517544 """
518545 budget = resolve_effective_budget (session .agent_id , self .config )
519546
520- # Session budget (per-agent, both kinds; backward-compat only).
547+ self ._check_session_budget (session )
548+
549+ kind = classify_agent_kind (session .agent_id )
550+ if kind .is_coding and kind .group :
551+ self ._check_coding_group_daily_budget (session , kind .group )
552+ else :
553+ self ._check_agent_daily_budget (session , budget )
554+
555+ def _check_session_budget (self , session : SessionRecord ) -> None :
556+ """Check the per-session cost threshold for an active or ended session."""
557+ budget = resolve_effective_budget (session .agent_id , self .config )
521558 if budget .session_usd is not None and session .total_cost_usd is not None :
522559 if session .total_cost_usd > budget .session_usd :
523560 alert = Alert (
@@ -536,12 +573,6 @@ def _check_cost_budgets(self, session: SessionRecord) -> None:
536573 )
537574 self ._fire (alert )
538575
539- kind = classify_agent_kind (session .agent_id )
540- if kind .is_coding and kind .group :
541- self ._check_coding_group_daily_budget (session , kind .group )
542- else :
543- self ._check_agent_daily_budget (session , budget )
544-
545576 def _check_coding_group_daily_budget (self , session : SessionRecord , group_id : str ) -> None :
546577 """Daily cap for a coding-tool GROUP: compares the SUM of today's
547578 spend across every agent_id that classifies into `group_id` (not
@@ -634,12 +665,18 @@ def _check_session_duration(self, session: SessionRecord) -> None:
634665
635666 def _fire (self , alert : Alert ) -> None :
636667 """Persist alert to DB and dispatch. Suppressed alerts are persisted but not dispatched."""
637- if self .cooldown .is_suppressed (alert .agent_id , alert .type ):
668+ session_key = (alert .session_id , alert .type .value )
669+ if alert .type in _SESSION_SCOPED_ALERT_TYPES and alert .session_id :
670+ if session_key in self ._session_limit_fired :
671+ return
672+ if self .cooldown .is_suppressed (alert .agent_id , alert .type , alert .session_id ):
638673 alert .suppressed = True
639674 self .db .insert_alert (alert )
640675 return
641676 self .db .insert_alert (alert )
642- self .cooldown .record (alert .agent_id , alert .type )
677+ if alert .type in _SESSION_SCOPED_ALERT_TYPES and alert .session_id :
678+ self ._session_limit_fired .add (session_key )
679+ self .cooldown .record (alert .agent_id , alert .type , alert .session_id )
643680 self .dispatcher .dispatch (alert )
644681
645682
0 commit comments