traceforge-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
traceforge/pipeline.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""Event pipeline that fans out events to registered storage sinks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Awaitable, Iterable
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from traceforge.enricher import Enricher
|
|
13
|
+
from traceforge.sinks.base import StorageSink
|
|
14
|
+
from traceforge.sinks.callback import (
|
|
15
|
+
CallbackSink,
|
|
16
|
+
EventCallback,
|
|
17
|
+
KindFilter,
|
|
18
|
+
as_async_event_callback,
|
|
19
|
+
)
|
|
20
|
+
from traceforge.types import EventMetadata, SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from traceforge.governance.results import SessionMeta
|
|
24
|
+
from traceforge.telemetry import PipelineMetrics
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
#: Default cap on the number of sessions whose live per-session stream state
|
|
29
|
+
#: (phase/boundary/title streams + lock) is retained at once. When exceeded, the
|
|
30
|
+
#: least-recently-used session is finalized (its held plumbing + trailing
|
|
31
|
+
#: activity title emitted) and evicted, so a long-lived multi-session daemon
|
|
32
|
+
#: does not grow unbounded. Generous enough that realistic concurrent workloads
|
|
33
|
+
#: never evict an active session; pass ``max_sessions=None`` to disable.
|
|
34
|
+
_DEFAULT_MAX_SESSIONS = 4096
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sink_label(index: int, sink: StorageSink) -> str:
|
|
38
|
+
"""Stable, human-readable per-sink metrics label (class name + position).
|
|
39
|
+
|
|
40
|
+
The index disambiguates two sinks of the same class (``"JsonlSink#0"`` vs
|
|
41
|
+
``"JsonlSink#1"``) so their write timings never merge.
|
|
42
|
+
"""
|
|
43
|
+
return f"{type(sink).__name__}#{index}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sink_labels_for(sinks: list[StorageSink]) -> list[str]:
|
|
47
|
+
"""Per-sink metrics labels aligned by position with ``sinks``."""
|
|
48
|
+
return [_sink_label(i, sink) for i, sink in enumerate(sinks)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EventPipeline:
|
|
52
|
+
"""Routes events, spans, and usage records to multiple storage sinks.
|
|
53
|
+
|
|
54
|
+
Sinks are error-isolated — one failing sink does not block others.
|
|
55
|
+
|
|
56
|
+
When a ``phase_inferencer`` is supplied, the pipeline becomes the
|
|
57
|
+
*phase producer*: it stamps ``metadata.phase`` on each event **live, as the
|
|
58
|
+
event flows through**, and emits it to the sinks immediately. The model is
|
|
59
|
+
fully causal (each event's phase depends only on its own prefix), so the
|
|
60
|
+
stamp is computed the moment the event arrives by featurising it against a
|
|
61
|
+
bounded trailing window of the session so far — no waiting for the session
|
|
62
|
+
to end. Only contiguous *leading* plumbing (before the first content-bearing
|
|
63
|
+
event) is briefly held, so it can inherit the first content phase; from the
|
|
64
|
+
first content event onward every event is stamped and emitted one at a time.
|
|
65
|
+
When ``phase_inferencer`` is ``None`` the pipeline streams events to sinks
|
|
66
|
+
immediately, exactly as before.
|
|
67
|
+
|
|
68
|
+
A ``boundary_inferencer`` (independent of ``phase_inferencer``) makes the
|
|
69
|
+
pipeline additionally stamp ``metadata.boundary`` live: the trained per-gap
|
|
70
|
+
classifier labels the transition into each event and marks the event that
|
|
71
|
+
*opens* a new activity/step. It is likewise fully causal and O(1) per
|
|
72
|
+
session — the gap into an event is known the instant that event arrives, so
|
|
73
|
+
nothing is buffered and nothing waits for session end.
|
|
74
|
+
|
|
75
|
+
**Both inferencers are enabled by default.** A client that does not pass an
|
|
76
|
+
explicit inferencer gets the packaged phase and boundary models wired in
|
|
77
|
+
automatically (loaded lazily on the first event). Set ``enable_phase=False``
|
|
78
|
+
and/or ``enable_boundary=False`` to opt out — e.g. for a transport-only
|
|
79
|
+
pipeline or when the packaged bundles are unavailable. An explicitly supplied
|
|
80
|
+
``phase_inferencer`` / ``boundary_inferencer`` always takes precedence over
|
|
81
|
+
its flag.
|
|
82
|
+
|
|
83
|
+
A ``title_inferencer`` (opt-in via ``enable_title=True``; off by default
|
|
84
|
+
because it pulls the heavier optional ONNX titler deps) consumes the
|
|
85
|
+
boundary-stamped stream and produces activity/step titles. It assigns each
|
|
86
|
+
segment a stable ``activity_id``/``step_id`` the instant it opens and stamps
|
|
87
|
+
that on every event, so events stream out **immediately** — never held back
|
|
88
|
+
for a title. A faithful title needs the segment's whole content, so when an
|
|
89
|
+
activity closes it is titled and the titles are emitted out-of-band as
|
|
90
|
+
append-only :class:`~traceforge.types.TitleUpdate` records (``on_title_update``)
|
|
91
|
+
keyed by segment id; consumers join them onto events. The trailing open
|
|
92
|
+
activity is titled at pipeline close.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
sinks: list[StorageSink],
|
|
98
|
+
enricher: Enricher | None = None,
|
|
99
|
+
phase_inferencer=None,
|
|
100
|
+
boundary_inferencer=None,
|
|
101
|
+
title_inferencer=None,
|
|
102
|
+
enable_phase: bool = True,
|
|
103
|
+
enable_boundary: bool = True,
|
|
104
|
+
enable_title: bool = False,
|
|
105
|
+
max_sessions: int | None = _DEFAULT_MAX_SESSIONS,
|
|
106
|
+
governance=None,
|
|
107
|
+
metrics: PipelineMetrics | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._sinks = list(sinks)
|
|
110
|
+
self._enricher = enricher
|
|
111
|
+
# Optional governance stage: any object exposing
|
|
112
|
+
# ``observe_event(event) -> SessionMeta | None``. When supplied, each event
|
|
113
|
+
# is scored and its SessionMeta stamped onto ``metadata.governance`` at the
|
|
114
|
+
# single sink choke point, so governance is one stage of the pipeline
|
|
115
|
+
# rather than a separate track. ``None`` (default) = pure observation, no
|
|
116
|
+
# governance, existing behaviour unchanged.
|
|
117
|
+
self._governance = governance
|
|
118
|
+
|
|
119
|
+
if phase_inferencer is None and enable_phase:
|
|
120
|
+
from traceforge.phase import PhaseInferencer
|
|
121
|
+
|
|
122
|
+
phase_inferencer = PhaseInferencer()
|
|
123
|
+
if boundary_inferencer is None and enable_boundary:
|
|
124
|
+
from traceforge.boundary import BoundaryInferencer
|
|
125
|
+
|
|
126
|
+
boundary_inferencer = BoundaryInferencer()
|
|
127
|
+
if title_inferencer is None and enable_title:
|
|
128
|
+
from traceforge.title import TitleInferencer
|
|
129
|
+
|
|
130
|
+
title_inferencer = TitleInferencer()
|
|
131
|
+
|
|
132
|
+
self._phase_inferencer = phase_inferencer
|
|
133
|
+
self._phase_streams: dict[str, object] = {}
|
|
134
|
+
self._boundary_inferencer = boundary_inferencer
|
|
135
|
+
self._boundary_streams: dict[str, object] = {}
|
|
136
|
+
self._title_inferencer = title_inferencer
|
|
137
|
+
self._title_streams: dict[str, object] = {}
|
|
138
|
+
# In-flight off-hot-path session-title API refinements, keyed by session
|
|
139
|
+
# so eviction can cancel a session's pending refinement (a stale refine
|
|
140
|
+
# must never emit after the session was evicted and its title stream
|
|
141
|
+
# replaced). Each is a tracked background task so live emission never
|
|
142
|
+
# blocks on the network; ``flush`` awaits any still-pending before
|
|
143
|
+
# teardown so no refinement is lost.
|
|
144
|
+
self._refine_tasks: dict[str, set[asyncio.Task]] = {}
|
|
145
|
+
|
|
146
|
+
# One lock per session serialises the stream-mutating push path. The
|
|
147
|
+
# phase/boundary/title streams hold unlocked per-session causal state and
|
|
148
|
+
# ``_title_emit`` yields the loop across its ``to_thread`` offload, so two
|
|
149
|
+
# events for the same session pushed concurrently (e.g. via
|
|
150
|
+
# ``asyncio.gather``) could otherwise interleave and corrupt that state
|
|
151
|
+
# silently. Different sessions take different locks, so cross-session
|
|
152
|
+
# throughput is unaffected. Lock creation is race-free: the get/insert
|
|
153
|
+
# below has no ``await`` between the miss and the store.
|
|
154
|
+
self._session_locks: dict[str, asyncio.Lock] = {}
|
|
155
|
+
|
|
156
|
+
# Recency of sessions holding live per-session state, most-recent last.
|
|
157
|
+
# Bounds memory for long-lived daemons: when the tracked count exceeds
|
|
158
|
+
# ``_max_sessions`` the least-recently-used session is finalized and
|
|
159
|
+
# evicted (see :meth:`_evict_over_cap`). ``None`` disables the cap
|
|
160
|
+
# (unbounded, reclaimed only at :meth:`flush`).
|
|
161
|
+
self._session_order: OrderedDict[str, None] = OrderedDict()
|
|
162
|
+
self._max_sessions = max_sessions
|
|
163
|
+
|
|
164
|
+
# Opt-in self-metrics (SPEC §14 / #48). When ``None`` (the default) the
|
|
165
|
+
# hot path never times or allocates for metrics: every instrumentation
|
|
166
|
+
# site is guarded on ``self._metrics is not None``, and ``_sink_labels``
|
|
167
|
+
# stays ``None`` so :meth:`_fanout` takes its original, unwrapped path.
|
|
168
|
+
# Sink labels are precomputed once (bounded by sink count) only when
|
|
169
|
+
# metrics are enabled, so per-event fan-out pays nothing when they are off.
|
|
170
|
+
self._metrics = metrics
|
|
171
|
+
self._sink_labels: list[str] | None = (
|
|
172
|
+
_sink_labels_for(self._sinks) if metrics is not None else None
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def metrics(self) -> PipelineMetrics | None:
|
|
177
|
+
"""The attached :class:`~traceforge.telemetry.PipelineMetrics`, or ``None``.
|
|
178
|
+
|
|
179
|
+
Metrics update live; read a stable summary with ``pipeline.metrics.snapshot()``
|
|
180
|
+
(e.g. after :meth:`flush` / :meth:`close`).
|
|
181
|
+
"""
|
|
182
|
+
return self._metrics
|
|
183
|
+
|
|
184
|
+
def subscribe(
|
|
185
|
+
self,
|
|
186
|
+
on_event: EventCallback,
|
|
187
|
+
*,
|
|
188
|
+
kind: KindFilter = None,
|
|
189
|
+
to_thread: bool = False,
|
|
190
|
+
) -> CallbackSink:
|
|
191
|
+
"""Register a lightweight event subscriber (SPEC §15 / #47).
|
|
192
|
+
|
|
193
|
+
One-line sugar over the sink model: wraps ``on_event`` in a
|
|
194
|
+
:class:`~traceforge.sinks.callback.CallbackSink` and appends it, so the
|
|
195
|
+
subscriber joins the pipeline's existing error-isolated fan-out — a
|
|
196
|
+
failing subscriber never blocks other sinks or the pipeline. This *is* the
|
|
197
|
+
publish/subscribe story: no sink subclassing, no flush/close lifecycle, no
|
|
198
|
+
persistence contract.
|
|
199
|
+
|
|
200
|
+
``on_event`` may be async *or* a plain sync callable (adapted via
|
|
201
|
+
:func:`~traceforge.sinks.callback.as_async_event_callback`); pass
|
|
202
|
+
``to_thread=True`` to run a blocking sync callback off the event loop.
|
|
203
|
+
``kind`` optionally filters which events reach this subscriber — an exact
|
|
204
|
+
kind, a ``"prefix.*"`` wildcard (e.g. ``"tool.*"``), an iterable of those,
|
|
205
|
+
or a predicate over the event — checked before dispatch.
|
|
206
|
+
|
|
207
|
+
Returns the created :class:`CallbackSink`, which doubles as the handle for
|
|
208
|
+
:meth:`unsubscribe`.
|
|
209
|
+
"""
|
|
210
|
+
sink = CallbackSink(
|
|
211
|
+
on_event=as_async_event_callback(on_event, kind=kind, to_thread=to_thread)
|
|
212
|
+
)
|
|
213
|
+
self._sinks.append(sink)
|
|
214
|
+
if self._sink_labels is not None:
|
|
215
|
+
self._sink_labels.append(_sink_label(len(self._sinks) - 1, sink))
|
|
216
|
+
return sink
|
|
217
|
+
|
|
218
|
+
def unsubscribe(self, sink: StorageSink) -> bool:
|
|
219
|
+
"""Remove a previously :meth:`subscribe`-d (or otherwise added) sink.
|
|
220
|
+
|
|
221
|
+
Returns ``True`` if the sink was present and removed, ``False`` otherwise.
|
|
222
|
+
Safe to call between pushes (the pipeline is single-threaded); an in-flight
|
|
223
|
+
fan-out already holds its coroutines, so removal only affects later pushes.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
self._sinks.remove(sink)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return False
|
|
229
|
+
if self._sink_labels is not None:
|
|
230
|
+
# Positions shifted; recompute so labels stay index-aligned with sinks.
|
|
231
|
+
self._sink_labels = _sink_labels_for(self._sinks)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def _session_lock(self, session_id: str) -> asyncio.Lock:
|
|
235
|
+
lock = self._session_locks.get(session_id)
|
|
236
|
+
if lock is None:
|
|
237
|
+
lock = asyncio.Lock()
|
|
238
|
+
self._session_locks[session_id] = lock
|
|
239
|
+
return lock
|
|
240
|
+
|
|
241
|
+
async def push(self, event: SessionEvent) -> None:
|
|
242
|
+
"""Fan-out event to all registered sinks.
|
|
243
|
+
|
|
244
|
+
Held under the session lock so concurrent pushes for the same session
|
|
245
|
+
are serialised into the live streams (ordering is the streams' causal
|
|
246
|
+
contract); pushes for distinct sessions still run concurrently.
|
|
247
|
+
|
|
248
|
+
Lifecycle contract: ``push`` is only valid before :meth:`flush`/
|
|
249
|
+
:meth:`close`. ``flush`` is terminal — it drains and reclaims all
|
|
250
|
+
per-session stream state *outside* the session lock — so pushing after
|
|
251
|
+
a flush (or concurrently with one) races the drain and is unsupported.
|
|
252
|
+
A batcher fanning pushes through :func:`asyncio.gather` is fine; a
|
|
253
|
+
batcher that periodically flushes to persist and then keeps pushing is
|
|
254
|
+
not.
|
|
255
|
+
|
|
256
|
+
For long-lived daemons the pipeline caps how many sessions' live state
|
|
257
|
+
it retains (``max_sessions``): after each push the least-recently-used
|
|
258
|
+
session beyond the cap is finalized and evicted (:meth:`_evict_over_cap`)
|
|
259
|
+
so memory stays bounded. Eviction runs *outside* this session's lock and
|
|
260
|
+
takes only the victim's own lock, so it never blocks or deadlocks the
|
|
261
|
+
push path.
|
|
262
|
+
"""
|
|
263
|
+
lock = await self._acquire_session(event.session_id)
|
|
264
|
+
try:
|
|
265
|
+
await self._push_locked(event)
|
|
266
|
+
finally:
|
|
267
|
+
lock.release()
|
|
268
|
+
self._session_order[event.session_id] = None
|
|
269
|
+
self._session_order.move_to_end(event.session_id)
|
|
270
|
+
await self._evict_over_cap()
|
|
271
|
+
|
|
272
|
+
async def _acquire_session(self, session_id: str) -> asyncio.Lock:
|
|
273
|
+
"""Acquire the session's *current* lock, tolerant of concurrent eviction.
|
|
274
|
+
|
|
275
|
+
Eviction may pop a session's lock (:meth:`_evict_over_cap`) between our
|
|
276
|
+
fetch and our acquire; a pusher already queued on that lock would then
|
|
277
|
+
wake holding a now-unregistered lock while a later pusher mints a fresh
|
|
278
|
+
one — two locks, one session, serialization broken. Guard against that by
|
|
279
|
+
re-checking after acquire: if the lock we hold is no longer the
|
|
280
|
+
registered one, release and retry, so every live pusher for a session
|
|
281
|
+
always converges on the single current lock object.
|
|
282
|
+
"""
|
|
283
|
+
while True:
|
|
284
|
+
lock = self._session_lock(session_id)
|
|
285
|
+
await lock.acquire()
|
|
286
|
+
if self._session_locks.get(session_id) is lock:
|
|
287
|
+
return lock
|
|
288
|
+
lock.release()
|
|
289
|
+
|
|
290
|
+
async def _evict_over_cap(self) -> None:
|
|
291
|
+
"""Finalize + evict least-recently-used sessions beyond ``max_sessions``.
|
|
292
|
+
|
|
293
|
+
Bounds per-session state for a long-lived multi-session daemon. Each
|
|
294
|
+
victim is the current oldest session; it is finalized under *its own*
|
|
295
|
+
lock (draining any held leading plumbing and titling its trailing open
|
|
296
|
+
activity, so no event or title is lost) and then dropped from every
|
|
297
|
+
per-session map. Running outside the caller's lock and taking at most one
|
|
298
|
+
lock at a time makes eviction deadlock-free; re-validating the victim
|
|
299
|
+
under its lock makes concurrent evictions and late pushes race-safe.
|
|
300
|
+
|
|
301
|
+
A session evicted this way that later receives another event simply
|
|
302
|
+
starts fresh (cold causal state) — acceptable only because the victim is,
|
|
303
|
+
by construction, the least-recently-active of ``max_sessions`` sessions.
|
|
304
|
+
"""
|
|
305
|
+
if self._max_sessions is None:
|
|
306
|
+
return
|
|
307
|
+
while len(self._session_order) > self._max_sessions:
|
|
308
|
+
victim = next(iter(self._session_order), None)
|
|
309
|
+
if victim is None:
|
|
310
|
+
return
|
|
311
|
+
lock = self._session_locks.get(victim)
|
|
312
|
+
if lock is None:
|
|
313
|
+
# No lock: mid-eviction by another task or never fully set up.
|
|
314
|
+
self._session_order.pop(victim, None)
|
|
315
|
+
continue
|
|
316
|
+
async with lock:
|
|
317
|
+
# Re-validate under the lock: still tracked, still the oldest,
|
|
318
|
+
# still over cap. A concurrent push may have re-touched it (no
|
|
319
|
+
# longer oldest) or another eviction may have removed it.
|
|
320
|
+
if (
|
|
321
|
+
victim not in self._session_order
|
|
322
|
+
or next(iter(self._session_order), None) != victim
|
|
323
|
+
or len(self._session_order) <= self._max_sessions
|
|
324
|
+
):
|
|
325
|
+
continue
|
|
326
|
+
self._session_order.pop(victim, None)
|
|
327
|
+
await self._finalize_session(victim)
|
|
328
|
+
# Drop the victim's lock only if it is still the one we held: a
|
|
329
|
+
# concurrent push never replaces a live lock (creation is guarded on
|
|
330
|
+
# absence), so this is the same object, but the identity check keeps
|
|
331
|
+
# eviction from ever removing a successor lock.
|
|
332
|
+
if self._session_locks.get(victim) is lock:
|
|
333
|
+
self._session_locks.pop(victim, None)
|
|
334
|
+
|
|
335
|
+
async def _finalize_session(self, session_id: str) -> None:
|
|
336
|
+
"""Drain + title a single session's trailing state, then drop it.
|
|
337
|
+
|
|
338
|
+
Mirrors :meth:`flush` for one session: cancel any in-flight session-title
|
|
339
|
+
refinement (so it can't emit after this session's title stream is gone),
|
|
340
|
+
emit any held leading plumbing (phase drain), title the trailing open
|
|
341
|
+
activity (title-stream flush), and discard the boundary stream. Called
|
|
342
|
+
under the session's lock by :meth:`_evict_over_cap`. The phase drain runs
|
|
343
|
+
first so every event has reached the title stream before it is flushed,
|
|
344
|
+
matching flush ordering.
|
|
345
|
+
"""
|
|
346
|
+
self._cancel_session_refinements(session_id)
|
|
347
|
+
if self._phase_inferencer is not None:
|
|
348
|
+
await self._drain_stream(session_id)
|
|
349
|
+
if self._title_inferencer is not None:
|
|
350
|
+
stream = self._title_streams.pop(session_id, None)
|
|
351
|
+
if stream is not None:
|
|
352
|
+
try:
|
|
353
|
+
updates = await asyncio.to_thread(stream.flush)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
logger.error(
|
|
356
|
+
"Title stream flush failed for evicted session %s: %s",
|
|
357
|
+
session_id,
|
|
358
|
+
exc,
|
|
359
|
+
exc_info=True,
|
|
360
|
+
)
|
|
361
|
+
updates = []
|
|
362
|
+
for update in updates:
|
|
363
|
+
await self._push_title_update(update)
|
|
364
|
+
# NOTE: eviction does not schedule API refinement for the trailing
|
|
365
|
+
# activity. It already cancelled this session's in-flight
|
|
366
|
+
# refinements above; finalizing with packaged titles only keeps a
|
|
367
|
+
# clean contract (evicted sessions get no API upgrades) and leaves
|
|
368
|
+
# no API task running after the stream is gone. Normally-completing
|
|
369
|
+
# sessions refine their trailing activity in _flush_title_streams.
|
|
370
|
+
self._boundary_streams.pop(session_id, None)
|
|
371
|
+
|
|
372
|
+
async def _push_locked(self, event: SessionEvent) -> None:
|
|
373
|
+
if self._enricher is not None:
|
|
374
|
+
metrics = self._metrics
|
|
375
|
+
# Time the enrichment step only when metrics are on: the conditional
|
|
376
|
+
# never calls the clock on the disabled path (it binds ``0.0``).
|
|
377
|
+
start = time.perf_counter() if metrics is not None else 0.0
|
|
378
|
+
try:
|
|
379
|
+
enriched = self._enricher.process(event)
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
logger.error(
|
|
382
|
+
"Enricher failed on event %s: %s — passing raw event to sinks",
|
|
383
|
+
event.id,
|
|
384
|
+
exc,
|
|
385
|
+
exc_info=True,
|
|
386
|
+
)
|
|
387
|
+
enriched = event
|
|
388
|
+
if metrics is not None:
|
|
389
|
+
metrics.record_enrichment(time.perf_counter() - start)
|
|
390
|
+
|
|
391
|
+
if enriched is None:
|
|
392
|
+
if metrics is not None:
|
|
393
|
+
metrics.record_drop()
|
|
394
|
+
return
|
|
395
|
+
if isinstance(enriched, list):
|
|
396
|
+
for e in enriched:
|
|
397
|
+
await self._emit(e)
|
|
398
|
+
return
|
|
399
|
+
event = enriched
|
|
400
|
+
|
|
401
|
+
await self._emit(event)
|
|
402
|
+
|
|
403
|
+
async def _emit(self, event: SessionEvent) -> None:
|
|
404
|
+
"""Route one enriched event to sinks, stamping phase live if enabled.
|
|
405
|
+
|
|
406
|
+
With phase inference enabled the event is handed to the session's live
|
|
407
|
+
per-event stream, which returns the events now ready to emit, each
|
|
408
|
+
already stamped: content-bearing events are classified the instant they
|
|
409
|
+
arrive (from bounded incremental causal state), plumbing events inherit
|
|
410
|
+
the prevailing content phase, and only contiguous leading plumbing is
|
|
411
|
+
briefly held so it can inherit the first content phase. Without
|
|
412
|
+
inference the event goes straight to sinks.
|
|
413
|
+
"""
|
|
414
|
+
if self._phase_inferencer is None:
|
|
415
|
+
await self._emit_ready([event])
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
stream = self._phase_streams.get(event.session_id)
|
|
419
|
+
if stream is None:
|
|
420
|
+
source = (event.metadata.source_framework if event.metadata else None) or ""
|
|
421
|
+
stream = self._phase_inferencer.new_stream(event.session_id, source)
|
|
422
|
+
self._phase_streams[event.session_id] = stream
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
ready = stream.push(event)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
logger.error(
|
|
428
|
+
"Live phase inference failed for event %s: %s — emitting unstamped",
|
|
429
|
+
event.id,
|
|
430
|
+
exc,
|
|
431
|
+
exc_info=True,
|
|
432
|
+
)
|
|
433
|
+
ready = [event]
|
|
434
|
+
|
|
435
|
+
await self._emit_ready(ready)
|
|
436
|
+
|
|
437
|
+
async def _emit_ready(self, events: list[SessionEvent]) -> None:
|
|
438
|
+
"""Stamp boundaries live (if enabled) on already-phase-resolved events,
|
|
439
|
+
then title + push them to sinks in order.
|
|
440
|
+
|
|
441
|
+
The boundary classifier labels the gap *into* each event and stamps the
|
|
442
|
+
opening label on the event that begins a new activity/step
|
|
443
|
+
(``metadata.boundary``). Like phase, it is fully causal and holds only
|
|
444
|
+
O(1) state per session, so it adds no session-end wait. A failing stream
|
|
445
|
+
degrades to emitting the event unstamped.
|
|
446
|
+
|
|
447
|
+
Boundary-stamped events then flow through the titler (if enabled), which
|
|
448
|
+
may buffer them until their activity closes — see :meth:`_title_emit`.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
for event in events:
|
|
452
|
+
if self._boundary_inferencer is not None:
|
|
453
|
+
event = self._boundary_stamp(event)
|
|
454
|
+
await self._title_emit(event)
|
|
455
|
+
|
|
456
|
+
async def _title_emit(self, event: SessionEvent) -> None:
|
|
457
|
+
"""Stamp the event's live segment ids, emit it immediately, then emit
|
|
458
|
+
any titles for the activity it just closed.
|
|
459
|
+
|
|
460
|
+
With titling disabled the event goes straight to sinks. Otherwise it is
|
|
461
|
+
handed to the session's live title stream, which stamps its
|
|
462
|
+
``activity_id``/``step_id`` and returns it for immediate emission plus —
|
|
463
|
+
when this event closes an activity — the append-only
|
|
464
|
+
:class:`~traceforge.types.TitleUpdate` records titling that activity and
|
|
465
|
+
its steps. The event log is never held back or mutated for a title; the
|
|
466
|
+
title arrives out-of-band keyed by segment id. A failing stream degrades
|
|
467
|
+
to emitting the event untitled.
|
|
468
|
+
|
|
469
|
+
Titling a closed activity (k steps + activity = k+1 model calls) is
|
|
470
|
+
offloaded to a worker thread so the async event loop is never blocked.
|
|
471
|
+
Pushes for one session stay strictly ordered because each call is awaited
|
|
472
|
+
before the next event reaches the same stream, and the titler is
|
|
473
|
+
CPU-capped (onnxruntime intra_op=1) so the offload never costs more than
|
|
474
|
+
a single core.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
if self._title_inferencer is None:
|
|
478
|
+
await self._push_to_sinks(event)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
stream = self._title_streams.get(event.session_id)
|
|
482
|
+
if stream is None:
|
|
483
|
+
source = (event.metadata.source_framework if event.metadata else None) or ""
|
|
484
|
+
stream = self._title_inferencer.new_stream(event.session_id, source)
|
|
485
|
+
self._title_streams[event.session_id] = stream
|
|
486
|
+
try:
|
|
487
|
+
event, updates = await asyncio.to_thread(stream.push, event)
|
|
488
|
+
except Exception as exc:
|
|
489
|
+
logger.error(
|
|
490
|
+
"Live title inference failed for event %s: %s — emitting untitled",
|
|
491
|
+
event.id,
|
|
492
|
+
exc,
|
|
493
|
+
exc_info=True,
|
|
494
|
+
)
|
|
495
|
+
updates = []
|
|
496
|
+
await self._push_to_sinks(event)
|
|
497
|
+
for update in updates:
|
|
498
|
+
await self._push_title_update(update)
|
|
499
|
+
|
|
500
|
+
# The session title was emitted above as the immediate heuristic. When an
|
|
501
|
+
# API tier is configured the stream queued the request text for an
|
|
502
|
+
# abstractive upgrade; run it off the hot path so the network never
|
|
503
|
+
# delays live emission and emit the result as a later session update.
|
|
504
|
+
refine_text = stream.take_session_refinement()
|
|
505
|
+
if refine_text is not None:
|
|
506
|
+
self._schedule_session_refine(event.session_id, refine_text)
|
|
507
|
+
|
|
508
|
+
# Likewise upgrade the just-closed activity's packaged titles off the hot
|
|
509
|
+
# path when an activity API tier is configured: the stream queued each
|
|
510
|
+
# closed activity (with its distilled context) for an abstractive upgrade.
|
|
511
|
+
# By default (strategy=model) nothing is queued, so this is a no-op.
|
|
512
|
+
for closed in stream.take_activity_refinements():
|
|
513
|
+
self._schedule_activity_refine(event.session_id, closed)
|
|
514
|
+
|
|
515
|
+
def _schedule_session_refine(self, session_id: str, text: str) -> None:
|
|
516
|
+
"""Refine a session title via the API off the hot path (fire-and-forget).
|
|
517
|
+
|
|
518
|
+
Spawns a tracked background task so live event emission is never blocked
|
|
519
|
+
on the network. The task is indexed by session so eviction can cancel it
|
|
520
|
+
(:meth:`_finalize_session`); :meth:`flush` awaits any still-pending
|
|
521
|
+
refinement before teardown so a slow-but-successful upgrade is never
|
|
522
|
+
dropped.
|
|
523
|
+
"""
|
|
524
|
+
task = asyncio.create_task(self._session_refine(session_id, text))
|
|
525
|
+
self._refine_tasks.setdefault(session_id, set()).add(task)
|
|
526
|
+
task.add_done_callback(lambda t: self._discard_refine_task(session_id, t))
|
|
527
|
+
|
|
528
|
+
def _discard_refine_task(self, session_id: str, task: asyncio.Task) -> None:
|
|
529
|
+
"""Drop a finished refinement task, reclaiming its session bucket."""
|
|
530
|
+
tasks = self._refine_tasks.get(session_id)
|
|
531
|
+
if tasks is None:
|
|
532
|
+
return
|
|
533
|
+
tasks.discard(task)
|
|
534
|
+
if not tasks:
|
|
535
|
+
self._refine_tasks.pop(session_id, None)
|
|
536
|
+
|
|
537
|
+
def _cancel_session_refinements(self, session_id: str) -> None:
|
|
538
|
+
"""Cancel a session's in-flight refinements (called on eviction).
|
|
539
|
+
|
|
540
|
+
A refinement started before eviction must not emit a title update after
|
|
541
|
+
the session's title stream has been dropped and possibly replaced by a
|
|
542
|
+
fresh stream on resume — that would clobber the newer heuristic. Cancel
|
|
543
|
+
happens strictly before any post-eviction re-push, so no stale refinement
|
|
544
|
+
can overwrite a later title.
|
|
545
|
+
"""
|
|
546
|
+
for task in list(self._refine_tasks.get(session_id, ())):
|
|
547
|
+
task.cancel()
|
|
548
|
+
|
|
549
|
+
async def _session_refine(self, session_id: str, text: str) -> None:
|
|
550
|
+
"""Compute the API session title in a worker thread and emit it.
|
|
551
|
+
|
|
552
|
+
Runs off the hot path. On empty output (unconfigured refiner, timeout, or
|
|
553
|
+
any provider error) the heuristic title already emitted stands and nothing
|
|
554
|
+
further is published. Error-isolated so a failing refinement never
|
|
555
|
+
propagates into the event loop.
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
refined = await asyncio.to_thread(self._title_inferencer.refine_title, text)
|
|
559
|
+
except Exception as exc:
|
|
560
|
+
logger.error(
|
|
561
|
+
"Session-title API refinement failed for session %s: %s — keeping heuristic",
|
|
562
|
+
session_id,
|
|
563
|
+
exc,
|
|
564
|
+
exc_info=True,
|
|
565
|
+
)
|
|
566
|
+
return
|
|
567
|
+
if not refined or not refined.strip():
|
|
568
|
+
return
|
|
569
|
+
await self._push_title_update(
|
|
570
|
+
TitleUpdate(
|
|
571
|
+
session_id=session_id,
|
|
572
|
+
segment_id=session_id,
|
|
573
|
+
kind="session",
|
|
574
|
+
title=refined,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def _schedule_activity_refine(self, session_id: str, closed) -> None:
|
|
579
|
+
"""Refine a closed activity's titles via the API off the hot path.
|
|
580
|
+
|
|
581
|
+
Mirrors :meth:`_schedule_session_refine`: spawns a tracked background task
|
|
582
|
+
indexed in the *same* ``_refine_tasks`` bucket, so eviction cancels it
|
|
583
|
+
(:meth:`_cancel_session_refinements`) and :meth:`flush` awaits it before
|
|
584
|
+
teardown. Live event emission is never blocked on the network.
|
|
585
|
+
"""
|
|
586
|
+
task = asyncio.create_task(self._activity_refine(session_id, closed))
|
|
587
|
+
self._refine_tasks.setdefault(session_id, set()).add(task)
|
|
588
|
+
task.add_done_callback(lambda t: self._discard_refine_task(session_id, t))
|
|
589
|
+
|
|
590
|
+
async def _activity_refine(self, session_id: str, closed) -> None:
|
|
591
|
+
"""Compute API activity/step titles in a worker thread and emit upgrades.
|
|
592
|
+
|
|
593
|
+
Runs off the hot path. Each returned
|
|
594
|
+
:class:`~traceforge.title.inferencer.TitleRefinement` becomes a later
|
|
595
|
+
append-only :class:`~traceforge.types.TitleUpdate` on the *same* segment
|
|
596
|
+
id, upgrading the packaged-model title. On empty output (unconfigured
|
|
597
|
+
refiner, no extractable signal, timeout, or any provider error) the
|
|
598
|
+
packaged titles already emitted stand and nothing further is published.
|
|
599
|
+
Error-isolated so a failing refinement never propagates into the loop.
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
refinements = await asyncio.to_thread(self._title_inferencer.refine_activity, closed)
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
logger.error(
|
|
605
|
+
"Activity-title API refinement failed for session %s: %s — keeping packaged titles",
|
|
606
|
+
session_id,
|
|
607
|
+
exc,
|
|
608
|
+
exc_info=True,
|
|
609
|
+
)
|
|
610
|
+
return
|
|
611
|
+
for ref in refinements:
|
|
612
|
+
await self._push_title_update(
|
|
613
|
+
TitleUpdate(
|
|
614
|
+
session_id=session_id,
|
|
615
|
+
segment_id=ref.segment_id,
|
|
616
|
+
kind=ref.kind,
|
|
617
|
+
title=ref.title,
|
|
618
|
+
parent_id=ref.parent_id,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _boundary_stamp(self, event: SessionEvent) -> SessionEvent:
|
|
623
|
+
"""Run one event through its session's live boundary stream."""
|
|
624
|
+
|
|
625
|
+
stream = self._boundary_streams.get(event.session_id)
|
|
626
|
+
if stream is None:
|
|
627
|
+
source = (event.metadata.source_framework if event.metadata else None) or ""
|
|
628
|
+
stream = self._boundary_inferencer.new_stream(event.session_id, source)
|
|
629
|
+
self._boundary_streams[event.session_id] = stream
|
|
630
|
+
try:
|
|
631
|
+
return stream.push(event)
|
|
632
|
+
except Exception as exc:
|
|
633
|
+
logger.error(
|
|
634
|
+
"Live boundary inference failed for event %s: %s — emitting unstamped",
|
|
635
|
+
event.id,
|
|
636
|
+
exc,
|
|
637
|
+
exc_info=True,
|
|
638
|
+
)
|
|
639
|
+
return event
|
|
640
|
+
|
|
641
|
+
async def _drain_stream(self, session_id: str) -> None:
|
|
642
|
+
"""Emit any leading plumbing a session held without a content event.
|
|
643
|
+
|
|
644
|
+
Streams persist for the whole ``session_id`` — they are NOT torn down on
|
|
645
|
+
``SESSION_ENDED``/``SESSION_PAUSED`` markers, which can appear mid-session
|
|
646
|
+
in resumed sessions and must not reset the causal feature state. Draining
|
|
647
|
+
happens only at pipeline flush/close.
|
|
648
|
+
"""
|
|
649
|
+
stream = self._phase_streams.pop(session_id, None)
|
|
650
|
+
if stream is None:
|
|
651
|
+
return
|
|
652
|
+
try:
|
|
653
|
+
leftover = stream.flush()
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
logger.error(
|
|
656
|
+
"Phase stream flush failed for session %s: %s", session_id, exc, exc_info=True
|
|
657
|
+
)
|
|
658
|
+
leftover = []
|
|
659
|
+
for ev in leftover:
|
|
660
|
+
if self._boundary_inferencer is not None:
|
|
661
|
+
ev = self._boundary_stamp(ev)
|
|
662
|
+
await self._title_emit(ev)
|
|
663
|
+
|
|
664
|
+
async def _flush_title_streams(self) -> None:
|
|
665
|
+
"""Title each session's final open activity and emit its updates.
|
|
666
|
+
|
|
667
|
+
Title streams persist for the whole ``session_id`` (never torn down on
|
|
668
|
+
mid-session SESSION_ENDED/PAUSED markers). The session's events have all
|
|
669
|
+
already been emitted live; at pipeline flush the trailing activity has no
|
|
670
|
+
closing boundary, so it is titled from its full context here and its
|
|
671
|
+
:class:`~traceforge.types.TitleUpdate` records are emitted.
|
|
672
|
+
"""
|
|
673
|
+
if self._title_inferencer is None:
|
|
674
|
+
return
|
|
675
|
+
for session_id in list(self._title_streams):
|
|
676
|
+
stream = self._title_streams.pop(session_id)
|
|
677
|
+
try:
|
|
678
|
+
updates = await asyncio.to_thread(stream.flush)
|
|
679
|
+
except Exception as exc:
|
|
680
|
+
logger.error(
|
|
681
|
+
"Title stream flush failed for session %s: %s",
|
|
682
|
+
session_id,
|
|
683
|
+
exc,
|
|
684
|
+
exc_info=True,
|
|
685
|
+
)
|
|
686
|
+
updates = []
|
|
687
|
+
for update in updates:
|
|
688
|
+
await self._push_title_update(update)
|
|
689
|
+
# Upgrade the trailing activity's packaged titles off the hot path
|
|
690
|
+
# when configured; flush() below awaits these before closing sinks.
|
|
691
|
+
for closed in stream.take_activity_refinements():
|
|
692
|
+
self._schedule_activity_refine(session_id, closed)
|
|
693
|
+
|
|
694
|
+
async def _fanout(
|
|
695
|
+
self,
|
|
696
|
+
coros: Iterable[Awaitable],
|
|
697
|
+
action: str,
|
|
698
|
+
labels: list[str] | None = None,
|
|
699
|
+
) -> None:
|
|
700
|
+
"""Await sink coroutines concurrently, error-isolated.
|
|
701
|
+
|
|
702
|
+
One failing sink is logged (with ``action`` naming the operation) and
|
|
703
|
+
skipped; it never blocks the others. The result order matches
|
|
704
|
+
``self._sinks``, so the logged index identifies the failing sink.
|
|
705
|
+
|
|
706
|
+
When self-metrics are enabled and ``labels`` (one per sink, aligned with
|
|
707
|
+
``coros``) is supplied, each sink call is wrapped to record its per-sink
|
|
708
|
+
write time and failure count. When metrics are off — or no labels are
|
|
709
|
+
passed — this is a strict no-op over the original behaviour: ``coros`` is
|
|
710
|
+
awaited exactly as before, with no wrapping and no clock.
|
|
711
|
+
"""
|
|
712
|
+
metrics = self._metrics
|
|
713
|
+
if metrics is not None and labels is not None:
|
|
714
|
+
coros = [self._timed_write(label, coro) for label, coro in zip(labels, coros)]
|
|
715
|
+
results = await asyncio.gather(*coros, return_exceptions=True)
|
|
716
|
+
for i, result in enumerate(results):
|
|
717
|
+
if isinstance(result, BaseException):
|
|
718
|
+
logger.error(
|
|
719
|
+
"Sink %d failed on %s: %s",
|
|
720
|
+
i,
|
|
721
|
+
action,
|
|
722
|
+
result,
|
|
723
|
+
exc_info=(type(result), result, result.__traceback__),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
async def _timed_write(self, label: str, coro: Awaitable) -> None:
|
|
727
|
+
"""Await one sink call, recording its wall time and failure into metrics.
|
|
728
|
+
|
|
729
|
+
Only reached on the metrics-enabled path. Re-raises so :meth:`_fanout`'s
|
|
730
|
+
error isolation still logs and contains the failure exactly as it would
|
|
731
|
+
for an unwrapped sink call.
|
|
732
|
+
"""
|
|
733
|
+
metrics = self._metrics
|
|
734
|
+
if metrics is None: # defensive; unreachable via _fanout's guard
|
|
735
|
+
await coro
|
|
736
|
+
return
|
|
737
|
+
start = time.perf_counter()
|
|
738
|
+
try:
|
|
739
|
+
await coro
|
|
740
|
+
except BaseException:
|
|
741
|
+
metrics.record_sink_write(label, time.perf_counter() - start, failed=True)
|
|
742
|
+
raise
|
|
743
|
+
metrics.record_sink_write(label, time.perf_counter() - start, failed=False)
|
|
744
|
+
|
|
745
|
+
async def _push_title_update(self, update: TitleUpdate) -> None:
|
|
746
|
+
"""Fan-out an append-only title update to all sinks. Error-isolated."""
|
|
747
|
+
await self._fanout(
|
|
748
|
+
(sink.on_title_update(update) for sink in self._sinks),
|
|
749
|
+
f"title update for segment {update.segment_id}",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
async def push_span(self, span: TelemetrySpan) -> None:
|
|
753
|
+
"""Fan-out span to all registered sinks."""
|
|
754
|
+
await self._fanout((sink.on_span(span) for sink in self._sinks), f"span {span.name}")
|
|
755
|
+
|
|
756
|
+
async def push_usage(self, usage: UsageRecord) -> None:
|
|
757
|
+
"""Fan-out usage record to all registered sinks."""
|
|
758
|
+
await self._fanout((sink.on_usage(usage) for sink in self._sinks), "usage record")
|
|
759
|
+
|
|
760
|
+
async def flush(self) -> None:
|
|
761
|
+
"""Drain all buffered state to sinks. TERMINAL — see :meth:`push`.
|
|
762
|
+
|
|
763
|
+
Flushes the enricher, drains each session's held leading plumbing and
|
|
764
|
+
final open activity, reclaims every per-session map, then flushes the
|
|
765
|
+
sinks. This runs *outside* the session lock and reclaims state, so no
|
|
766
|
+
``push`` may run during or after it (the caller stops pushing, then
|
|
767
|
+
flushes/closes). Error-isolated.
|
|
768
|
+
"""
|
|
769
|
+
if self._enricher is not None:
|
|
770
|
+
for event in self._enricher.flush():
|
|
771
|
+
await self._emit(event)
|
|
772
|
+
|
|
773
|
+
# Drain any sessions still holding leading plumbing (no content event
|
|
774
|
+
# and no explicit SESSION_ENDED seen). Steady-state events are already
|
|
775
|
+
# emitted live, so only the leading hold-buffer can remain.
|
|
776
|
+
if self._phase_inferencer is not None:
|
|
777
|
+
for session_id in list(self._phase_streams):
|
|
778
|
+
await self._drain_stream(session_id)
|
|
779
|
+
|
|
780
|
+
# Title each session's final open activity. Its events were already
|
|
781
|
+
# emitted live (carrying their segment ids); the trailing activity has
|
|
782
|
+
# no closing boundary, so it is titled here and its TitleUpdate records
|
|
783
|
+
# emitted. Done after phase drain so every event has reached the title
|
|
784
|
+
# stream first.
|
|
785
|
+
await self._flush_title_streams()
|
|
786
|
+
|
|
787
|
+
# Await any in-flight session-title API refinements so a slow-but-
|
|
788
|
+
# successful upgrade lands (as its own title update) before the sinks are
|
|
789
|
+
# flushed and closed. Error-isolated: a failed refinement already logged
|
|
790
|
+
# inside the task and left the heuristic standing.
|
|
791
|
+
pending = [task for tasks in self._refine_tasks.values() for task in tasks]
|
|
792
|
+
if pending:
|
|
793
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
794
|
+
|
|
795
|
+
# Boundary streams and per-session locks carry no buffered output to
|
|
796
|
+
# drain (boundary is O(1) causal state; locks are bare mutexes), but
|
|
797
|
+
# they are per-session and would otherwise outlive every finalized
|
|
798
|
+
# session. Reclaim them here so all four per-session maps free together
|
|
799
|
+
# at teardown, matching the phase/title drain above. Safe because flush
|
|
800
|
+
# is terminal: no push holds a lock or touches a boundary stream now.
|
|
801
|
+
self._boundary_streams.clear()
|
|
802
|
+
self._session_locks.clear()
|
|
803
|
+
self._session_order.clear()
|
|
804
|
+
|
|
805
|
+
await self._fanout((sink.flush() for sink in self._sinks), "flush")
|
|
806
|
+
|
|
807
|
+
if self._metrics is not None:
|
|
808
|
+
logger.debug("EventPipeline self-metrics at flush: %s", self._metrics.snapshot())
|
|
809
|
+
|
|
810
|
+
async def _push_to_sinks(self, event: SessionEvent) -> None:
|
|
811
|
+
"""Push event to sinks, stamping governance first if a stage is wired in.
|
|
812
|
+
|
|
813
|
+
Governance is the pipeline's final enrichment stage: it runs after the
|
|
814
|
+
live phase/boundary/title structuring, so the SessionMeta it produces is
|
|
815
|
+
attached to the fully-structured event, then the event fans out to every
|
|
816
|
+
sink. Every event passes through here (the single sink choke point), so
|
|
817
|
+
this is where governance belongs as one stage of the pipeline.
|
|
818
|
+
|
|
819
|
+
When governance produces a ``SessionMeta``, the event and its meta are
|
|
820
|
+
wrapped in an :class:`~traceforge.governance.envelope.EnrichedEvent` and
|
|
821
|
+
dispatched via ``sink.on_enriched_event`` — the ``{event, _governance}``
|
|
822
|
+
envelope. This is backward compatible: the event is *also* stamped with
|
|
823
|
+
``metadata.governance`` (as before), and the base ``on_enriched_event``
|
|
824
|
+
default forwards a live event to ``on_event``, so a sink that only
|
|
825
|
+
implements ``on_event`` sees byte-identical output. When governance is not
|
|
826
|
+
wired, or produces no meta for this event kind, the bare event goes to
|
|
827
|
+
``on_event`` exactly as before.
|
|
828
|
+
"""
|
|
829
|
+
if self._metrics is not None:
|
|
830
|
+
self._metrics.record_event()
|
|
831
|
+
if self._governance is None:
|
|
832
|
+
await self._fanout(
|
|
833
|
+
(sink.on_event(event) for sink in self._sinks),
|
|
834
|
+
f"event {event.id}",
|
|
835
|
+
self._sink_labels,
|
|
836
|
+
)
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
stamped, meta = self._annotate_governance(event)
|
|
840
|
+
if meta is None:
|
|
841
|
+
await self._fanout(
|
|
842
|
+
(sink.on_event(stamped) for sink in self._sinks),
|
|
843
|
+
f"event {stamped.id}",
|
|
844
|
+
self._sink_labels,
|
|
845
|
+
)
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
from traceforge.governance.envelope import EnrichedEvent
|
|
849
|
+
|
|
850
|
+
enriched = EnrichedEvent(event=stamped, governance=meta)
|
|
851
|
+
await self._fanout(
|
|
852
|
+
(sink.on_enriched_event(enriched) for sink in self._sinks),
|
|
853
|
+
f"enriched event {stamped.id}",
|
|
854
|
+
self._sink_labels,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _annotate_governance(
|
|
858
|
+
self, event: SessionEvent
|
|
859
|
+
) -> tuple[SessionEvent, "SessionMeta | None"]:
|
|
860
|
+
"""Score one event through the governance stage and stamp its SessionMeta.
|
|
861
|
+
|
|
862
|
+
Returns ``(event, meta)``: a copy of the event with
|
|
863
|
+
``metadata.governance`` populated, alongside the ``SessionMeta`` itself
|
|
864
|
+
(or ``(event, None)`` when the stage produces no governance for this event
|
|
865
|
+
kind). The stage is error-isolated: on any governance failure the original
|
|
866
|
+
event is returned unchanged with ``None`` meta (logged), so the
|
|
867
|
+
observation stream is never blocked by the governance stage.
|
|
868
|
+
"""
|
|
869
|
+
try:
|
|
870
|
+
meta = self._governance.observe_event(event)
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
logger.error(
|
|
873
|
+
"Governance stage failed on event %s: %s -- emitting ungoverned",
|
|
874
|
+
event.id,
|
|
875
|
+
exc,
|
|
876
|
+
exc_info=True,
|
|
877
|
+
)
|
|
878
|
+
return event, None
|
|
879
|
+
if meta is None:
|
|
880
|
+
return event, None
|
|
881
|
+
metadata = event.metadata
|
|
882
|
+
if metadata is None:
|
|
883
|
+
metadata = EventMetadata.model_construct(governance=meta)
|
|
884
|
+
else:
|
|
885
|
+
metadata = metadata.model_copy(update={"governance": meta})
|
|
886
|
+
return event.model_copy(update={"metadata": metadata}), meta
|
|
887
|
+
|
|
888
|
+
async def close(self) -> None:
|
|
889
|
+
"""Flush then close all sinks. Error-isolated."""
|
|
890
|
+
await self.flush()
|
|
891
|
+
await self._fanout((sink.close() for sink in self._sinks), "close")
|