events.py 13 KB

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