events.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. """Canonical root event journal and ordered, failure-isolated projection pump."""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. from typing import Any
  6. from weakref import WeakValueDictionary
  7. from pydantic import Field
  8. from cyber_agent.application.candidate import CandidateLedger
  9. from cyber_agent.application.models import ApplicationModel, ApplicationRef
  10. from cyber_agent.application.ports import RunEventProjector
  11. from cyber_agent.core.task_protocol import ensure_task_protocol
  12. logger = logging.getLogger(__name__)
  13. CURRENT_RUN_EVENT_SCHEMA_VERSION = 1
  14. SUPPORTED_RUN_EVENT_TYPES = frozenset({
  15. "task.progress.revised",
  16. "candidate.version_registered",
  17. "candidate.lifecycle_changed",
  18. "validation.completed",
  19. "task.review.recorded",
  20. "run.rewound",
  21. })
  22. _ROOT_LOCKS: "WeakValueDictionary[tuple[int, str], asyncio.Lock]" = (
  23. WeakValueDictionary()
  24. )
  25. class RunEvent(ApplicationModel):
  26. event_id: int = Field(gt=0)
  27. schema_version: int = Field(ge=1)
  28. event_type: str = Field(min_length=1, max_length=200)
  29. event_key: str = Field(min_length=1, max_length=500)
  30. application_ref: ApplicationRef
  31. root_trace_id: str = Field(min_length=1)
  32. source_trace_id: str = Field(min_length=1)
  33. effective_at_sequence: int = Field(ge=0)
  34. payload: dict[str, Any]
  35. class RunEventPump:
  36. """Project canonical events in journal order without touching Agent state."""
  37. def __init__(
  38. self,
  39. *,
  40. store: Any,
  41. application_ref: ApplicationRef,
  42. projector: RunEventProjector | None,
  43. ) -> None:
  44. self.store = store
  45. self.application_ref = application_ref
  46. self.projector = projector
  47. async def pump(self, root_trace_id: str) -> int:
  48. if self.projector is None:
  49. return 0
  50. cursor = await self.projector.load_cursor(
  51. self.application_ref,
  52. root_trace_id,
  53. )
  54. projected = 0
  55. for raw in await self.store.get_events(root_trace_id, cursor):
  56. if raw.get("event") != "run_event":
  57. continue
  58. event = RunEvent.model_validate({
  59. key: raw[key]
  60. for key in RunEvent.model_fields
  61. if key in raw
  62. })
  63. if (
  64. event.application_ref != self.application_ref
  65. or event.root_trace_id != root_trace_id
  66. ):
  67. raise ValueError("run_event application/root identity mismatch")
  68. if (
  69. event.schema_version != CURRENT_RUN_EVENT_SCHEMA_VERSION
  70. or event.event_type not in SUPPORTED_RUN_EVENT_TYPES
  71. ):
  72. await self.projector.advance_cursor(
  73. self.application_ref,
  74. root_trace_id,
  75. event.event_id,
  76. cursor,
  77. )
  78. else:
  79. await self.projector.project(event, cursor)
  80. next_cursor = await self.projector.load_cursor(
  81. self.application_ref,
  82. root_trace_id,
  83. )
  84. if next_cursor != event.event_id:
  85. raise ValueError(
  86. "RunEventProjector did not atomically advance its cursor"
  87. )
  88. cursor = next_cursor
  89. projected += 1
  90. return projected
  91. class RunEventService:
  92. """Append idempotent canonical events after authoritative state commits."""
  93. def __init__(
  94. self,
  95. *,
  96. store: Any,
  97. application_ref: ApplicationRef,
  98. projector: RunEventProjector | None = None,
  99. ) -> None:
  100. self.store = store
  101. self.application_ref = application_ref
  102. self.pump = RunEventPump(
  103. store=store,
  104. application_ref=application_ref,
  105. projector=projector,
  106. )
  107. @staticmethod
  108. def _lock(root_trace_id: str) -> asyncio.Lock:
  109. key = (id(asyncio.get_running_loop()), root_trace_id)
  110. lock = _ROOT_LOCKS.get(key)
  111. if lock is None:
  112. lock = asyncio.Lock()
  113. _ROOT_LOCKS[key] = lock
  114. return lock
  115. async def emit(
  116. self,
  117. *,
  118. source_trace_id: str,
  119. event_type: str,
  120. event_key: str,
  121. effective_at_sequence: int,
  122. payload: dict[str, Any],
  123. project: bool = True,
  124. ) -> int:
  125. source = await self.store.get_trace(source_trace_id)
  126. if source is None:
  127. raise ValueError(f"Trace not found: {source_trace_id}")
  128. root_trace_id = source.context.get("root_trace_id") or source.trace_id
  129. if source.context.get("application_ref") != self.application_ref.model_dump(
  130. mode="json"
  131. ):
  132. raise ValueError("run_event source application binding mismatch")
  133. async with self._lock(root_trace_id):
  134. existing = next(
  135. (
  136. item for item in await self.store.get_events(root_trace_id)
  137. if item.get("event") == "run_event"
  138. and item.get("event_key") == event_key
  139. ),
  140. None,
  141. )
  142. if existing is not None:
  143. expected = {
  144. "schema_version": CURRENT_RUN_EVENT_SCHEMA_VERSION,
  145. "event_type": event_type,
  146. "event_key": event_key,
  147. "application_ref": self.application_ref.model_dump(
  148. mode="json"
  149. ),
  150. "root_trace_id": root_trace_id,
  151. "source_trace_id": source_trace_id,
  152. "effective_at_sequence": effective_at_sequence,
  153. "payload": payload,
  154. }
  155. actual = {
  156. key: existing.get(key)
  157. for key in expected
  158. }
  159. if actual != expected:
  160. raise ValueError(
  161. f"run_event key was reused with conflicting payload: {event_key}"
  162. )
  163. event_id = int(existing["event_id"])
  164. else:
  165. event_id = await self.store.append_event(
  166. root_trace_id,
  167. "run_event",
  168. {
  169. "schema_version": CURRENT_RUN_EVENT_SCHEMA_VERSION,
  170. "event_type": event_type,
  171. "event_key": event_key,
  172. "application_ref": self.application_ref.model_dump(
  173. mode="json"
  174. ),
  175. "root_trace_id": root_trace_id,
  176. "source_trace_id": source_trace_id,
  177. "effective_at_sequence": effective_at_sequence,
  178. "payload": payload,
  179. },
  180. )
  181. if project:
  182. await self.try_pump(root_trace_id)
  183. return event_id
  184. async def emit_after_commit(self, **kwargs: Any) -> None:
  185. """Never roll back protocol state because journaling/projection failed."""
  186. try:
  187. await self.emit(project=False, **kwargs)
  188. except Exception:
  189. logger.exception("Failed to emit/project canonical run event")
  190. async def try_pump(self, root_trace_id: str) -> None:
  191. try:
  192. await self.pump.pump(root_trace_id)
  193. except Exception:
  194. logger.exception(
  195. "RunEventProjector stopped at its current cursor for root %s",
  196. root_trace_id,
  197. )
  198. async def reconcile_root(self, root_trace_id: str) -> int:
  199. """Rebuild missing canonical events from current authoritative records."""
  200. traces = [
  201. item for item in await self.store.list_traces(limit=10_000)
  202. if (item.context.get("root_trace_id") or item.trace_id) == root_trace_id
  203. and item.context.get("application_ref")
  204. == self.application_ref.model_dump(mode="json")
  205. ]
  206. emitted = 0
  207. for trace in traces:
  208. state = ensure_task_protocol(trace.context)
  209. for revision in state.get("task_progress_revisions", []):
  210. emitted += bool(await self.emit(
  211. source_trace_id=trace.trace_id,
  212. event_type="task.progress.revised",
  213. event_key=(
  214. f"task.progress.revised:{trace.trace_id}:"
  215. f"{revision['revision']}"
  216. ),
  217. effective_at_sequence=revision["effective_at_sequence"],
  218. payload={"revision": revision},
  219. project=False,
  220. ))
  221. cache = state.get("task_report_validation")
  222. if isinstance(cache, dict) and cache.get("aggregate_result"):
  223. emitted += bool(await self.emit(
  224. source_trace_id=trace.trace_id,
  225. event_type="validation.completed",
  226. event_key=(
  227. f"validation.completed:trace:{trace.trace_id}:"
  228. f"{cache['plan_hash']}"
  229. ),
  230. effective_at_sequence=int(
  231. cache.get("validated_at_sequence", 0) or 0
  232. ),
  233. payload={
  234. "subject_type": "trace",
  235. "validation_result": cache["aggregate_result"],
  236. },
  237. project=False,
  238. ))
  239. for review in state.get("reviews", []):
  240. sequence = int(review.get("reviewed_at_sequence", 0) or 0)
  241. emitted += bool(await self.emit(
  242. source_trace_id=trace.trace_id,
  243. event_type="task.review.recorded",
  244. event_key=(
  245. f"task.review.recorded:{trace.trace_id}:"
  246. f"{review.get('child_trace_id')}:{sequence}"
  247. ),
  248. effective_at_sequence=sequence,
  249. payload={"task_review": review},
  250. project=False,
  251. ))
  252. for operation in trace.context.get("run_event_operations", []):
  253. if operation.get("event_type") != "run.rewound":
  254. continue
  255. emitted += bool(await self.emit(
  256. source_trace_id=trace.trace_id,
  257. event_type="run.rewound",
  258. event_key=f"run.rewound:{operation['operation_id']}",
  259. effective_at_sequence=operation["effective_at_sequence"],
  260. payload=operation["payload"],
  261. project=False,
  262. ))
  263. for raw in await self.store.get_events(trace.trace_id):
  264. if raw.get("event") != "rewind":
  265. continue
  266. sequence = int(raw.get("after_sequence", 0) or 0)
  267. emitted += bool(await self.emit(
  268. source_trace_id=trace.trace_id,
  269. event_type="run.rewound",
  270. event_key=(
  271. f"run.rewound:{raw['operation_id']}"
  272. if raw.get("operation_id")
  273. else f"run.rewound:{trace.trace_id}:"
  274. f"{raw['event_id']}:{sequence}"
  275. ),
  276. effective_at_sequence=sequence,
  277. payload={"after_sequence": sequence},
  278. project=False,
  279. ))
  280. ledger = CandidateLedger.model_validate(
  281. await self.store.get_candidate_ledger(root_trace_id) or {}
  282. )
  283. for candidate in ledger.candidates:
  284. emitted += bool(await self.emit(
  285. source_trace_id=candidate.owner_trace_id,
  286. event_type="candidate.version_registered",
  287. event_key=(
  288. f"candidate.version_registered:{candidate.candidate_id}:"
  289. f"{candidate.revision}"
  290. ),
  291. effective_at_sequence=candidate.created_at_sequence,
  292. payload={"candidate_ref": candidate.model_dump(mode="json")},
  293. project=False,
  294. ))
  295. for lifecycle in ledger.lifecycle:
  296. candidate = ledger.candidate(lifecycle.candidate)
  297. emitted += bool(await self.emit(
  298. source_trace_id=lifecycle.source_trace_id or candidate.owner_trace_id,
  299. event_type="candidate.lifecycle_changed",
  300. event_key=(
  301. f"candidate.lifecycle_changed:{lifecycle.operation_id}:"
  302. f"{lifecycle.state}"
  303. ),
  304. effective_at_sequence=lifecycle.effective_at_sequence,
  305. payload={"lifecycle": lifecycle.model_dump(mode="json")},
  306. project=False,
  307. ))
  308. for raw in ledger.validations:
  309. candidate = raw["candidate_ref"]
  310. emitted += bool(await self.emit(
  311. source_trace_id=candidate["owner_trace_id"],
  312. event_type="validation.completed",
  313. event_key=(
  314. f"validation.completed:candidate:{candidate['candidate_id']}:"
  315. f"{candidate['revision']}:{raw['plan_hash']}"
  316. ),
  317. effective_at_sequence=raw["validated_at_sequence"],
  318. payload={
  319. "subject_type": "candidate",
  320. "candidate_ref": candidate,
  321. "validation_result": raw["validation_result"],
  322. },
  323. project=False,
  324. ))
  325. await self.try_pump(root_trace_id)
  326. return emitted