events.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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_record(
  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. ) -> tuple[int, bool]:
  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. created = False
  165. else:
  166. event_id = await self.store.append_event(
  167. root_trace_id,
  168. "run_event",
  169. {
  170. "schema_version": CURRENT_RUN_EVENT_SCHEMA_VERSION,
  171. "event_type": event_type,
  172. "event_key": event_key,
  173. "application_ref": self.application_ref.model_dump(
  174. mode="json"
  175. ),
  176. "root_trace_id": root_trace_id,
  177. "source_trace_id": source_trace_id,
  178. "effective_at_sequence": effective_at_sequence,
  179. "payload": payload,
  180. },
  181. )
  182. created = True
  183. if project:
  184. await self.try_pump(root_trace_id)
  185. return event_id, created
  186. async def emit(self, **kwargs: Any) -> int:
  187. event_id, _created = await self._emit_record(**kwargs)
  188. return event_id
  189. async def _emit_missing(self, **kwargs: Any) -> int:
  190. _event_id, created = await self._emit_record(**kwargs)
  191. return int(created)
  192. async def emit_after_commit(self, **kwargs: Any) -> None:
  193. """Never roll back protocol state because journaling/projection failed."""
  194. try:
  195. await self.emit(project=False, **kwargs)
  196. except Exception:
  197. logger.exception("Failed to emit/project canonical run event")
  198. async def try_pump(self, root_trace_id: str) -> None:
  199. try:
  200. await self.pump.pump(root_trace_id)
  201. except Exception:
  202. logger.exception(
  203. "RunEventProjector stopped at its current cursor for root %s",
  204. root_trace_id,
  205. )
  206. async def reconcile_root(self, root_trace_id: str) -> int:
  207. """Rebuild missing canonical events from current authoritative records."""
  208. traces = [
  209. item for item in await self.store.list_traces(limit=10_000)
  210. if (item.context.get("root_trace_id") or item.trace_id) == root_trace_id
  211. and item.context.get("application_ref")
  212. == self.application_ref.model_dump(mode="json")
  213. ]
  214. emitted = 0
  215. for trace in traces:
  216. state = ensure_task_protocol(trace.context)
  217. for revision in state.get("task_progress_revisions", []):
  218. emitted += await self._emit_missing(
  219. source_trace_id=trace.trace_id,
  220. event_type="task.progress.revised",
  221. event_key=(
  222. f"task.progress.revised:{trace.trace_id}:"
  223. f"{revision['revision']}"
  224. ),
  225. effective_at_sequence=revision["effective_at_sequence"],
  226. payload={"revision": revision},
  227. project=False,
  228. )
  229. cache = state.get("task_report_validation")
  230. if isinstance(cache, dict) and cache.get("aggregate_result"):
  231. emitted += await self._emit_missing(
  232. source_trace_id=trace.trace_id,
  233. event_type="validation.completed",
  234. event_key=(
  235. f"validation.completed:trace:{trace.trace_id}:"
  236. f"{cache['plan_hash']}"
  237. ),
  238. effective_at_sequence=int(
  239. cache.get("validated_at_sequence", 0) or 0
  240. ),
  241. payload={
  242. "subject_type": "trace",
  243. "validation_result": cache["aggregate_result"],
  244. },
  245. project=False,
  246. )
  247. for review in state.get("reviews", []):
  248. sequence = int(review.get("reviewed_at_sequence", 0) or 0)
  249. emitted += await self._emit_missing(
  250. source_trace_id=trace.trace_id,
  251. event_type="task.review.recorded",
  252. event_key=(
  253. f"task.review.recorded:{trace.trace_id}:"
  254. f"{review.get('child_trace_id')}:{sequence}"
  255. ),
  256. effective_at_sequence=sequence,
  257. payload={"task_review": review},
  258. project=False,
  259. )
  260. for operation in trace.context.get("run_event_operations", []):
  261. if operation.get("event_type") != "run.rewound":
  262. continue
  263. emitted += await self._emit_missing(
  264. source_trace_id=trace.trace_id,
  265. event_type="run.rewound",
  266. event_key=f"run.rewound:{operation['operation_id']}",
  267. effective_at_sequence=operation["effective_at_sequence"],
  268. payload=operation["payload"],
  269. project=False,
  270. )
  271. for raw in await self.store.get_events(trace.trace_id):
  272. if raw.get("event") != "rewind":
  273. continue
  274. sequence = int(raw.get("after_sequence", 0) or 0)
  275. emitted += await self._emit_missing(
  276. source_trace_id=trace.trace_id,
  277. event_type="run.rewound",
  278. event_key=(
  279. f"run.rewound:{raw['operation_id']}"
  280. if raw.get("operation_id")
  281. else f"run.rewound:{trace.trace_id}:"
  282. f"{raw['event_id']}:{sequence}"
  283. ),
  284. effective_at_sequence=sequence,
  285. payload={"after_sequence": sequence},
  286. project=False,
  287. )
  288. ledger = CandidateLedger.model_validate(
  289. await self.store.get_candidate_ledger(root_trace_id) or {}
  290. )
  291. for candidate in ledger.candidates:
  292. emitted += await self._emit_missing(
  293. source_trace_id=candidate.owner_trace_id,
  294. event_type="candidate.version_registered",
  295. event_key=(
  296. f"candidate.version_registered:{candidate.candidate_id}:"
  297. f"{candidate.revision}"
  298. ),
  299. effective_at_sequence=candidate.created_at_sequence,
  300. payload={"candidate_ref": candidate.model_dump(mode="json")},
  301. project=False,
  302. )
  303. for lifecycle in ledger.lifecycle:
  304. candidate = ledger.candidate(lifecycle.candidate)
  305. emitted += await self._emit_missing(
  306. source_trace_id=lifecycle.source_trace_id or candidate.owner_trace_id,
  307. event_type="candidate.lifecycle_changed",
  308. event_key=(
  309. f"candidate.lifecycle_changed:{lifecycle.operation_id}:"
  310. f"{lifecycle.state}"
  311. ),
  312. effective_at_sequence=lifecycle.effective_at_sequence,
  313. payload={"lifecycle": lifecycle.model_dump(mode="json")},
  314. project=False,
  315. )
  316. for raw in ledger.validations:
  317. candidate = raw["candidate_ref"]
  318. emitted += await self._emit_missing(
  319. source_trace_id=candidate["owner_trace_id"],
  320. event_type="validation.completed",
  321. event_key=(
  322. f"validation.completed:candidate:{candidate['candidate_id']}:"
  323. f"{candidate['revision']}:{raw['plan_hash']}"
  324. ),
  325. effective_at_sequence=raw["validated_at_sequence"],
  326. payload={
  327. "subject_type": "candidate",
  328. "candidate_ref": candidate,
  329. "validation_result": raw["validation_result"],
  330. },
  331. project=False,
  332. )
  333. await self.try_pump(root_trace_id)
  334. return emitted