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
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""The single-writer observation path for governance.
|
|
2
|
+
|
|
3
|
+
SessionMonitor is the only component that mutates and persists session state. It
|
|
4
|
+
owns the durable write pipeline: idempotency reservation, atomic Phase-1 commit,
|
|
5
|
+
Phase 2/3 finalization, crash recovery, the Phase 2/3 circuit breaker, deferred
|
|
6
|
+
MCP writes, and session-summary finalization. It also owns the reservation
|
|
7
|
+
bookkeeping (write-failure and Phase-2/3 retry counters). The read side
|
|
8
|
+
(previews, scoring) lives in the Scorer; the Monitor never previews.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from traceforge.governance.results import SessionMeta
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import traceforge.types
|
|
22
|
+
|
|
23
|
+
from traceforge.governance.assessor import Assessor
|
|
24
|
+
from traceforge.governance.codec import MetaCodec
|
|
25
|
+
from traceforge.governance.context import ContextBuilder
|
|
26
|
+
from traceforge.governance.persistence import SystemStore
|
|
27
|
+
from traceforge.governance.phase1 import Phase1
|
|
28
|
+
from traceforge.governance.registry import SessionRegistry
|
|
29
|
+
from traceforge.governance.state import SessionState, SessionStateSnapshot
|
|
30
|
+
from traceforge.governance.types import EnrichmentContext
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionMonitor:
|
|
34
|
+
"""Single writer: observe events, mutate + persist session state, finalize."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
context: "ContextBuilder",
|
|
39
|
+
phase1: "Phase1",
|
|
40
|
+
assessor: "Assessor",
|
|
41
|
+
registry: "SessionRegistry",
|
|
42
|
+
store: "SystemStore",
|
|
43
|
+
codec: "MetaCodec",
|
|
44
|
+
) -> None:
|
|
45
|
+
self._context = context
|
|
46
|
+
self._phase1 = phase1
|
|
47
|
+
self._assessor = assessor
|
|
48
|
+
self._registry = registry
|
|
49
|
+
self._store = store
|
|
50
|
+
self._codec = codec
|
|
51
|
+
self._write_failures: dict[str, int] = {} # session_id → consecutive failure count
|
|
52
|
+
self._MAX_WRITE_FAILURES = 10
|
|
53
|
+
self._phase23_attempts: dict[str, int] = {} # source_event_key → attempt count
|
|
54
|
+
self._phase23_session_keys: dict[
|
|
55
|
+
str, set[str]
|
|
56
|
+
] = {} # session_id → set of event keys with attempts
|
|
57
|
+
self._MAX_PHASE23_ATTEMPTS = 3
|
|
58
|
+
|
|
59
|
+
def observe_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta | None":
|
|
60
|
+
"""Observation-path scoring for use as a live pipeline stage.
|
|
61
|
+
|
|
62
|
+
Unlike :meth:`Scorer.score_tool_call_event` (read-only preflight), this
|
|
63
|
+
runs the state-mutating observation path and returns the ``SessionMeta``
|
|
64
|
+
to stamp onto the event's ``metadata.governance`` — or ``None`` when the
|
|
65
|
+
event is not governance-relevant.
|
|
66
|
+
|
|
67
|
+
This is the method :class:`~traceforge.pipeline.EventPipeline` calls when a
|
|
68
|
+
governance stage is wired in, making governance one stage of the pipeline
|
|
69
|
+
rather than a separate track. Because the stage sees *every* event kind at
|
|
70
|
+
the sink choke point, it must dispatch by kind:
|
|
71
|
+
|
|
72
|
+
* Session lifecycle (``session.started`` / ``session.ended``) →
|
|
73
|
+
:meth:`process_lifecycle` (Phase 1 only; ``session.ended`` also
|
|
74
|
+
finalizes the summary and evicts session state).
|
|
75
|
+
* Tool calls → the state-mutating :meth:`process_event`. Every
|
|
76
|
+
``tool.call.completed`` is scored; a ``tool.call.started`` is scored
|
|
77
|
+
only when it is an id-bearing orphan (an unpaired start flushed at
|
|
78
|
+
session end / pipeline close, or a displaced duplicate), since the
|
|
79
|
+
Enricher buffers a paired start into its completion and emits a no-id
|
|
80
|
+
start's completion separately. This keeps each real tool call observed
|
|
81
|
+
exactly once.
|
|
82
|
+
* Any other kind (messages, turns, llm/planning chunks, spans, usage, …)
|
|
83
|
+
is *not* a tool call and must not advance the tool-call budget, so it is
|
|
84
|
+
left ungoverned (``None`` → no ``metadata.governance`` stamp).
|
|
85
|
+
"""
|
|
86
|
+
from traceforge.types import EventKind
|
|
87
|
+
|
|
88
|
+
kind = event.kind
|
|
89
|
+
if kind == EventKind.SESSION_STARTED:
|
|
90
|
+
return self.process_lifecycle(event.session_id, "session_start")
|
|
91
|
+
if kind == EventKind.SESSION_ENDED:
|
|
92
|
+
return self.process_lifecycle(event.session_id, "session_end")
|
|
93
|
+
if kind == EventKind.TOOL_CALL_COMPLETED:
|
|
94
|
+
return self.process_event(self._context.from_session_event(event))
|
|
95
|
+
if kind == EventKind.TOOL_CALL_STARTED:
|
|
96
|
+
# A started event only reaches the stage (rather than being buffered)
|
|
97
|
+
# as either a genuine orphan — an unpaired start flushed at session
|
|
98
|
+
# end / pipeline close, or a displaced duplicate, all of which carry a
|
|
99
|
+
# tool_call_id and MUST be scored since no completion will — or a no-id
|
|
100
|
+
# "provisional" start the Enricher cannot pair, whose completion is
|
|
101
|
+
# emitted and scored separately. Scoring the latter would double-count,
|
|
102
|
+
# so discriminate on the same id the Enricher pairs on.
|
|
103
|
+
from traceforge.enricher import _extract_tool_call_id
|
|
104
|
+
|
|
105
|
+
if _extract_tool_call_id(event) is None:
|
|
106
|
+
return None
|
|
107
|
+
return self.process_event(self._context.from_session_event(event))
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_or_create_state(self, session_id: str) -> "SessionState":
|
|
111
|
+
"""Get or create session state, rehydrating from the store on a miss."""
|
|
112
|
+
return self._registry.get_or_create(session_id)
|
|
113
|
+
|
|
114
|
+
def process_lifecycle(self, session_id: str, event_kind: str) -> SessionMeta:
|
|
115
|
+
"""Handle session_start/end — Phase 1 only, skip Phase 2/3.
|
|
116
|
+
|
|
117
|
+
Lifecycle deliveries are idempotent. Each ``(session_id, event_kind)`` is
|
|
118
|
+
recorded in the ``processed_events`` store under its lifecycle idempotency
|
|
119
|
+
key, and a re-delivery short-circuits to a true no-op that never touches
|
|
120
|
+
session state. This closes the session_end resurrection bug: without the
|
|
121
|
+
dedup guard a duplicate session_end would call ``get_or_create_state``,
|
|
122
|
+
rehydrating the just-evicted session back into the durable registry, and
|
|
123
|
+
re-run finalization against that rehydrated state. For session_end the
|
|
124
|
+
summary write and the idempotency record commit inside a single
|
|
125
|
+
``write_transaction`` so they are atomic — the event is marked processed
|
|
126
|
+
only if its summary durably landed.
|
|
127
|
+
"""
|
|
128
|
+
from traceforge.governance.state import BudgetSnapshot
|
|
129
|
+
from traceforge.governance.types import compute_source_event_key
|
|
130
|
+
|
|
131
|
+
# Reuse the canonical lifecycle key so retries with different adapter
|
|
132
|
+
# timestamps map to the same start/end event.
|
|
133
|
+
lifecycle_key = compute_source_event_key(session_id=session_id, event_kind=event_kind)
|
|
134
|
+
|
|
135
|
+
# A re-delivered lifecycle event is a true no-op: return without touching
|
|
136
|
+
# state, so an ended session is never resurrected into the registry.
|
|
137
|
+
if self._store.is_duplicate(lifecycle_key) is not None:
|
|
138
|
+
return self._lifecycle_meta(BudgetSnapshot())
|
|
139
|
+
|
|
140
|
+
marker_json = json.dumps({"lifecycle": event_kind})
|
|
141
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
142
|
+
|
|
143
|
+
if event_kind == "session_end":
|
|
144
|
+
state = self.get_or_create_state(session_id)
|
|
145
|
+
snapshot = state.snapshot()
|
|
146
|
+
try:
|
|
147
|
+
# Summary write + idempotency record commit atomically: the event is
|
|
148
|
+
# marked processed only if its finalized summary durably landed.
|
|
149
|
+
with self._store.write_transaction():
|
|
150
|
+
self._write_session_summary_no_commit(session_id, snapshot, now)
|
|
151
|
+
self._store.execute_in_transaction(
|
|
152
|
+
"INSERT OR IGNORE INTO processed_events "
|
|
153
|
+
"(source_event_key, session_id, session_meta_json, processed_at) "
|
|
154
|
+
"VALUES (?, ?, ?, ?)",
|
|
155
|
+
(lifecycle_key, session_id, marker_json, now),
|
|
156
|
+
)
|
|
157
|
+
self._store.cache_processed(lifecycle_key, marker_json)
|
|
158
|
+
except (sqlite3.OperationalError, sqlite3.IntegrityError) as e:
|
|
159
|
+
import logging
|
|
160
|
+
|
|
161
|
+
logging.getLogger(__name__).warning(
|
|
162
|
+
"Failed to finalize session_end for %s: %s — will retry on next delivery",
|
|
163
|
+
session_id,
|
|
164
|
+
e,
|
|
165
|
+
)
|
|
166
|
+
# Rolled back and NOT marked processed: leave the state resident so a
|
|
167
|
+
# re-delivery re-runs finalization.
|
|
168
|
+
return self._lifecycle_meta(snapshot.budget)
|
|
169
|
+
|
|
170
|
+
# Evict only after the summary + idempotency record durably commit. Both
|
|
171
|
+
# residencies (durable + gate) are dropped on session end.
|
|
172
|
+
self._registry.evict(session_id)
|
|
173
|
+
self._registry.evict_gate(session_id)
|
|
174
|
+
self._write_failures.pop(session_id, None)
|
|
175
|
+
# Clean up any lingering phase23 attempts for this session's events
|
|
176
|
+
for key in self._phase23_session_keys.pop(session_id, set()):
|
|
177
|
+
self._phase23_attempts.pop(key, None)
|
|
178
|
+
return self._lifecycle_meta(snapshot.budget)
|
|
179
|
+
|
|
180
|
+
# session_start (and any other non-end lifecycle kind): no finalization side
|
|
181
|
+
# effects today, but record the delivery so the idempotency contract is
|
|
182
|
+
# uniform and a duplicate is recognized as a no-op without resurrecting state.
|
|
183
|
+
state = self.get_or_create_state(session_id)
|
|
184
|
+
snapshot = state.snapshot()
|
|
185
|
+
try:
|
|
186
|
+
with self._store.write_transaction():
|
|
187
|
+
self._store.execute_in_transaction(
|
|
188
|
+
"INSERT OR IGNORE INTO processed_events "
|
|
189
|
+
"(source_event_key, session_id, session_meta_json, processed_at) "
|
|
190
|
+
"VALUES (?, ?, ?, ?)",
|
|
191
|
+
(lifecycle_key, session_id, marker_json, now),
|
|
192
|
+
)
|
|
193
|
+
self._store.cache_processed(lifecycle_key, marker_json)
|
|
194
|
+
except (sqlite3.OperationalError, sqlite3.IntegrityError) as e:
|
|
195
|
+
import logging
|
|
196
|
+
|
|
197
|
+
logging.getLogger(__name__).warning(
|
|
198
|
+
"Failed to record %s for %s: %s — will retry on next delivery",
|
|
199
|
+
event_kind,
|
|
200
|
+
session_id,
|
|
201
|
+
e,
|
|
202
|
+
)
|
|
203
|
+
return self._lifecycle_meta(snapshot.budget)
|
|
204
|
+
|
|
205
|
+
def _lifecycle_meta(self, budget: "BudgetSnapshot") -> SessionMeta:
|
|
206
|
+
"""Build the minimal Phase-1-only SessionMeta returned for lifecycle events."""
|
|
207
|
+
return SessionMeta(
|
|
208
|
+
classification=None,
|
|
209
|
+
risk_assessment=None,
|
|
210
|
+
recommendation=None,
|
|
211
|
+
budget_snapshot=budget,
|
|
212
|
+
drift=None,
|
|
213
|
+
mcp_alerts=(),
|
|
214
|
+
evidence=None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def process_event(self, ctx: "EnrichmentContext") -> SessionMeta:
|
|
218
|
+
"""Full pipeline: Phase 1 → Phase 2 → Phase 3 → SessionMeta."""
|
|
219
|
+
|
|
220
|
+
event = ctx.event
|
|
221
|
+
session_id = event.session_id
|
|
222
|
+
|
|
223
|
+
# ── Phase 1: State Mutation ──
|
|
224
|
+
# Idempotency check BEFORE loading state (prevents resurrection of ended sessions)
|
|
225
|
+
existing = self._store.is_duplicate(event.source_event_key)
|
|
226
|
+
if existing:
|
|
227
|
+
meta_dict = json.loads(existing)
|
|
228
|
+
if not meta_dict.get("reserved"):
|
|
229
|
+
return self._codec.deserialize_meta(meta_dict)
|
|
230
|
+
# Reserved = Phase 1 completed atomically. Skip Phase 1, re-run Phase 2/3 only.
|
|
231
|
+
# Restore attempt count from persisted reservation (survives restarts)
|
|
232
|
+
persisted_attempts = meta_dict.get("phase23_attempts", 0)
|
|
233
|
+
if event.source_event_key not in self._phase23_attempts:
|
|
234
|
+
self._phase23_attempts[event.source_event_key] = persisted_attempts
|
|
235
|
+
|
|
236
|
+
state = self.get_or_create_state(session_id)
|
|
237
|
+
|
|
238
|
+
if not existing:
|
|
239
|
+
# Phase 1 mutations (in-memory) — wrapped for crash recovery
|
|
240
|
+
try:
|
|
241
|
+
self._phase1.apply(ctx, state)
|
|
242
|
+
except Exception as phase1_exc:
|
|
243
|
+
import logging
|
|
244
|
+
|
|
245
|
+
logging.getLogger(__name__).error(
|
|
246
|
+
"Phase 1 mutation failed for session %s event %s: %s — discarding state",
|
|
247
|
+
session_id,
|
|
248
|
+
event.source_event_key,
|
|
249
|
+
phase1_exc,
|
|
250
|
+
)
|
|
251
|
+
# Discard corrupted in-memory state — reload clean from DB
|
|
252
|
+
self._registry.evict(session_id)
|
|
253
|
+
state = self.get_or_create_state(session_id)
|
|
254
|
+
return SessionMeta(
|
|
255
|
+
classification=None,
|
|
256
|
+
risk_assessment=None,
|
|
257
|
+
recommendation=None,
|
|
258
|
+
budget_snapshot=state.snapshot().budget,
|
|
259
|
+
drift=None,
|
|
260
|
+
mcp_alerts=(),
|
|
261
|
+
evidence=None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Atomic commit: state persist + reservation in single transaction
|
|
265
|
+
# Include Phase-1 snapshot in reservation so retries use event-time state
|
|
266
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
267
|
+
snapshot_for_reservation = state.snapshot()
|
|
268
|
+
reservation_data = {
|
|
269
|
+
"reserved": True,
|
|
270
|
+
"snapshot": self._codec.serialize_snapshot(snapshot_for_reservation),
|
|
271
|
+
}
|
|
272
|
+
reservation_json = json.dumps(reservation_data)
|
|
273
|
+
try:
|
|
274
|
+
with self._store.write_transaction():
|
|
275
|
+
state.persist_no_commit()
|
|
276
|
+
self._store.execute_in_transaction(
|
|
277
|
+
"INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
|
|
278
|
+
(event.source_event_key, session_id, reservation_json, now),
|
|
279
|
+
)
|
|
280
|
+
self._write_failures[session_id] = 0
|
|
281
|
+
self._store.cache_processed(event.source_event_key, reservation_json)
|
|
282
|
+
except (sqlite3.OperationalError, sqlite3.IntegrityError) as e:
|
|
283
|
+
import logging
|
|
284
|
+
|
|
285
|
+
logging.getLogger(__name__).warning(
|
|
286
|
+
"Atomic Phase 1 commit failed for session %s: %s — discarding in-memory mutations, will retry on next delivery",
|
|
287
|
+
session_id,
|
|
288
|
+
e,
|
|
289
|
+
)
|
|
290
|
+
# write_transaction already rolled back — discard the corrupted
|
|
291
|
+
# in-memory state and reload a clean copy from the DB.
|
|
292
|
+
self._registry.evict(session_id)
|
|
293
|
+
state = self.get_or_create_state(session_id)
|
|
294
|
+
# Return degraded response — event will be re-delivered
|
|
295
|
+
return SessionMeta(
|
|
296
|
+
classification=None,
|
|
297
|
+
risk_assessment=None,
|
|
298
|
+
recommendation=None,
|
|
299
|
+
budget_snapshot=state.snapshot().budget,
|
|
300
|
+
drift=None,
|
|
301
|
+
mcp_alerts=(),
|
|
302
|
+
evidence=None,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# ── Phase 2: Labeling (side-effect-free) ──
|
|
306
|
+
# Circuit breaker: if Phase 2/3 crashes consistently, dead-letter the event
|
|
307
|
+
# For retries (existing=reserved), use the persisted event-time snapshot
|
|
308
|
+
if existing:
|
|
309
|
+
snapshot_data = meta_dict.get("snapshot")
|
|
310
|
+
if not snapshot_data:
|
|
311
|
+
# Every reservation persists an event-time snapshot (see the
|
|
312
|
+
# reservation write below), so its absence is a corrupt/foreign
|
|
313
|
+
# record, not a recoverable state — fail loudly rather than
|
|
314
|
+
# silently reassessing against drifted current state.
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"reserved event {event.source_event_key} has no persisted "
|
|
317
|
+
"snapshot; refusing to reassess against current state"
|
|
318
|
+
)
|
|
319
|
+
snapshot = self._codec.deserialize_snapshot(snapshot_data)
|
|
320
|
+
else:
|
|
321
|
+
snapshot = snapshot_for_reservation
|
|
322
|
+
try:
|
|
323
|
+
assessment = self._assessor.assess(ctx, snapshot)
|
|
324
|
+
except Exception as phase23_exc:
|
|
325
|
+
import logging
|
|
326
|
+
|
|
327
|
+
logger = logging.getLogger(__name__)
|
|
328
|
+
# Increment attempt counter and dead-letter after max retries
|
|
329
|
+
attempts = self._phase23_attempts.get(event.source_event_key, 0) + 1
|
|
330
|
+
self._phase23_attempts[event.source_event_key] = attempts
|
|
331
|
+
# Track which session owns this key for cleanup on session_end
|
|
332
|
+
self._phase23_session_keys.setdefault(session_id, set()).add(event.source_event_key)
|
|
333
|
+
if attempts >= self._MAX_PHASE23_ATTEMPTS:
|
|
334
|
+
logger.error(
|
|
335
|
+
"Event %s failed Phase 2/3 %d times — dead-lettering: %s",
|
|
336
|
+
event.source_event_key,
|
|
337
|
+
attempts,
|
|
338
|
+
phase23_exc,
|
|
339
|
+
)
|
|
340
|
+
# Finalize with degraded meta so event stops retrying
|
|
341
|
+
degraded_meta = SessionMeta(
|
|
342
|
+
classification=None,
|
|
343
|
+
risk_assessment=None,
|
|
344
|
+
recommendation=None,
|
|
345
|
+
budget_snapshot=snapshot.budget,
|
|
346
|
+
drift=None,
|
|
347
|
+
mcp_alerts=(),
|
|
348
|
+
evidence=None,
|
|
349
|
+
)
|
|
350
|
+
degraded_json = json.dumps(
|
|
351
|
+
{
|
|
352
|
+
**self._codec.serialize_meta(degraded_meta),
|
|
353
|
+
"dead_lettered": True,
|
|
354
|
+
"error": str(phase23_exc),
|
|
355
|
+
"attempts": attempts,
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
with self._store.write_transaction():
|
|
360
|
+
self._store.execute_in_transaction(
|
|
361
|
+
"UPDATE processed_events SET session_meta_json = ? WHERE source_event_key = ?",
|
|
362
|
+
(degraded_json, event.source_event_key),
|
|
363
|
+
)
|
|
364
|
+
self._store.cache_processed(event.source_event_key, degraded_json)
|
|
365
|
+
# Only clear attempts after successful dead-letter persistence
|
|
366
|
+
del self._phase23_attempts[event.source_event_key]
|
|
367
|
+
# Clean session key tracking
|
|
368
|
+
dl_keys = self._phase23_session_keys.get(session_id)
|
|
369
|
+
if dl_keys:
|
|
370
|
+
dl_keys.discard(event.source_event_key)
|
|
371
|
+
if not dl_keys:
|
|
372
|
+
del self._phase23_session_keys[session_id]
|
|
373
|
+
except (sqlite3.OperationalError, sqlite3.IntegrityError):
|
|
374
|
+
# write_transaction already rolled back — keep the attempt
|
|
375
|
+
# count so the next retry re-attempts dead-lettering.
|
|
376
|
+
pass
|
|
377
|
+
return degraded_meta
|
|
378
|
+
else:
|
|
379
|
+
logger.warning(
|
|
380
|
+
"Event %s Phase 2/3 attempt %d/%d failed: %s — will retry on next delivery",
|
|
381
|
+
event.source_event_key,
|
|
382
|
+
attempts,
|
|
383
|
+
self._MAX_PHASE23_ATTEMPTS,
|
|
384
|
+
phase23_exc,
|
|
385
|
+
)
|
|
386
|
+
# Persist attempt count in reservation so it survives process restarts
|
|
387
|
+
# Preserve the snapshot so retries still use event-time state
|
|
388
|
+
try:
|
|
389
|
+
reservation_json = json.dumps(
|
|
390
|
+
{
|
|
391
|
+
"reserved": True,
|
|
392
|
+
"phase23_attempts": attempts,
|
|
393
|
+
"snapshot": self._codec.serialize_snapshot(snapshot),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
with self._store.write_transaction():
|
|
397
|
+
self._store.execute_in_transaction(
|
|
398
|
+
"UPDATE processed_events SET session_meta_json = ? WHERE source_event_key = ?",
|
|
399
|
+
(reservation_json, event.source_event_key),
|
|
400
|
+
)
|
|
401
|
+
self._store.cache_processed(event.source_event_key, reservation_json)
|
|
402
|
+
except (sqlite3.OperationalError, sqlite3.IntegrityError):
|
|
403
|
+
pass # write_transaction already rolled back
|
|
404
|
+
return SessionMeta(
|
|
405
|
+
classification=None,
|
|
406
|
+
risk_assessment=None,
|
|
407
|
+
recommendation=None,
|
|
408
|
+
budget_snapshot=snapshot.budget,
|
|
409
|
+
drift=None,
|
|
410
|
+
mcp_alerts=(),
|
|
411
|
+
evidence=None,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Phase 2/3 succeeded — clear retry counter ONLY after finalization commits (below)
|
|
415
|
+
phase23_key_to_clear = event.source_event_key
|
|
416
|
+
|
|
417
|
+
# Assessment succeeded — surface its verdict + deferred MCP writes
|
|
418
|
+
meta = assessment.meta
|
|
419
|
+
|
|
420
|
+
# Finalize idempotency record + deferred MCP writes in single transaction
|
|
421
|
+
try:
|
|
422
|
+
meta_json = json.dumps(self._codec.serialize_meta(meta))
|
|
423
|
+
with self._store.write_transaction():
|
|
424
|
+
self._store.execute_in_transaction(
|
|
425
|
+
"UPDATE processed_events SET session_meta_json = ? WHERE source_event_key = ?",
|
|
426
|
+
(meta_json, event.source_event_key),
|
|
427
|
+
)
|
|
428
|
+
if assessment.mcp_deferred_writes:
|
|
429
|
+
self._commit_mcp_writes_no_commit(assessment.mcp_deferred_writes)
|
|
430
|
+
if assessment.integrity_deferred_writes:
|
|
431
|
+
self._commit_integrity_writes_no_commit(assessment.integrity_deferred_writes)
|
|
432
|
+
self._store.cache_processed(event.source_event_key, meta_json)
|
|
433
|
+
except (
|
|
434
|
+
sqlite3.OperationalError,
|
|
435
|
+
sqlite3.IntegrityError,
|
|
436
|
+
json.JSONDecodeError,
|
|
437
|
+
KeyError,
|
|
438
|
+
TypeError,
|
|
439
|
+
ValueError,
|
|
440
|
+
AttributeError,
|
|
441
|
+
) as e:
|
|
442
|
+
import logging
|
|
443
|
+
|
|
444
|
+
logging.getLogger(__name__).error(
|
|
445
|
+
"Finalization commit failed for event %s: %s — will retry on next delivery",
|
|
446
|
+
event.source_event_key,
|
|
447
|
+
e,
|
|
448
|
+
)
|
|
449
|
+
# write_transaction already rolled back — event stays reserved so the
|
|
450
|
+
# next delivery re-runs Phase 2/3. Do NOT clear the retry counter.
|
|
451
|
+
return SessionMeta(
|
|
452
|
+
classification=meta.classification,
|
|
453
|
+
risk_assessment=meta.risk_assessment,
|
|
454
|
+
recommendation=meta.recommendation,
|
|
455
|
+
budget_snapshot=snapshot.budget,
|
|
456
|
+
drift=None,
|
|
457
|
+
mcp_alerts=(),
|
|
458
|
+
evidence=None,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Only clear retry counter after successful finalization commit
|
|
462
|
+
self._phase23_attempts.pop(phase23_key_to_clear, None)
|
|
463
|
+
# Also clean session key tracking to prevent unbounded growth
|
|
464
|
+
session_keys = self._phase23_session_keys.get(session_id)
|
|
465
|
+
if session_keys:
|
|
466
|
+
session_keys.discard(phase23_key_to_clear)
|
|
467
|
+
if not session_keys:
|
|
468
|
+
del self._phase23_session_keys[session_id]
|
|
469
|
+
return meta
|
|
470
|
+
|
|
471
|
+
def _commit_mcp_writes_no_commit(self, writes: tuple) -> None:
|
|
472
|
+
"""Execute deferred MCP writes without committing — caller owns transaction.
|
|
473
|
+
|
|
474
|
+
Delegates to the store's ``*_no_commit`` helpers so each write lands in the
|
|
475
|
+
normalized tables (``mcp_profiles`` + ``mcp_profile_attributes``) inside the
|
|
476
|
+
caller's :meth:`SystemStore.write_transaction`.
|
|
477
|
+
"""
|
|
478
|
+
for write in writes:
|
|
479
|
+
if write.kind == "upsert":
|
|
480
|
+
self._store.write_mcp_profile_no_commit(
|
|
481
|
+
write.server, write.tool_name, json.loads(write.payload)
|
|
482
|
+
)
|
|
483
|
+
elif write.kind == "last_seen":
|
|
484
|
+
self._store.write_mcp_last_seen_no_commit(
|
|
485
|
+
write.server, write.tool_name, write.payload
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def _commit_integrity_writes_no_commit(self, writes: tuple) -> None:
|
|
489
|
+
"""Persist deferred content-hash baselines without committing — caller owns transaction.
|
|
490
|
+
|
|
491
|
+
Runs after :meth:`Assessor.assess` has already checked each write against the
|
|
492
|
+
prior baseline, so this only (re)baselines to what the agent wrote, stamped with
|
|
493
|
+
the writing session + timestamp. Committed atomically with the idempotency record.
|
|
494
|
+
"""
|
|
495
|
+
for write in writes:
|
|
496
|
+
self._store.store_content_hash_no_commit(
|
|
497
|
+
write.repo,
|
|
498
|
+
write.path,
|
|
499
|
+
write.sha256,
|
|
500
|
+
write.session_id,
|
|
501
|
+
write.timestamp,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def _write_session_summary_no_commit(
|
|
505
|
+
self, session_id: str, snapshot: "SessionStateSnapshot", now: str
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Write the session summary row on the caller's OPEN transaction (no commit).
|
|
508
|
+
|
|
509
|
+
``INSERT OR IGNORE`` keeps the first delivery authoritative: the started/
|
|
510
|
+
ended timestamps, event counts, and budget snapshot are recorded once, and a
|
|
511
|
+
duplicate delivery is a no-op at the SQL level. The caller owns the
|
|
512
|
+
surrounding ``write_transaction`` so the summary commits atomically with the
|
|
513
|
+
lifecycle idempotency record.
|
|
514
|
+
"""
|
|
515
|
+
import json as json_mod
|
|
516
|
+
|
|
517
|
+
budget_snapshot_json = json_mod.dumps(
|
|
518
|
+
{
|
|
519
|
+
"total_tool_calls": snapshot.budget.total_tool_calls,
|
|
520
|
+
"total_tokens": snapshot.budget.total_tokens,
|
|
521
|
+
"pressure": snapshot.budget.pressure,
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
# INSERT OR IGNORE: first delivery records started_at/ended_at; duplicates
|
|
525
|
+
# are no-ops. The caller's write_transaction serializes the commit on the
|
|
526
|
+
# shared connection (single writer).
|
|
527
|
+
self._store.execute_in_transaction(
|
|
528
|
+
"""INSERT OR IGNORE INTO session_summaries
|
|
529
|
+
(session_id, started_at, ended_at, total_events, dropped_events, budget_snapshot_json)
|
|
530
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
531
|
+
(
|
|
532
|
+
session_id,
|
|
533
|
+
now,
|
|
534
|
+
now,
|
|
535
|
+
snapshot.event_count,
|
|
536
|
+
snapshot.dropped_events,
|
|
537
|
+
budget_snapshot_json,
|
|
538
|
+
),
|
|
539
|
+
)
|