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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. 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
+ )