candidate_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """Candidate command service with one root-level consistency boundary."""
  2. from __future__ import annotations
  3. import asyncio
  4. from hashlib import sha256
  5. from typing import Any
  6. from weakref import WeakValueDictionary
  7. from cyber_agent.application.candidate import (
  8. CandidateLedger,
  9. CandidateLifecycleRecord,
  10. CandidateOperationRecord,
  11. CandidatePointer,
  12. CandidateRef,
  13. CandidateRepository,
  14. CandidateVersionRequest,
  15. )
  16. from cyber_agent.application.models import canonical_json
  17. from cyber_agent.core.agent_mode import require_mutable_trace_policy
  18. from cyber_agent.core.artifacts import ArtifactRef, ArtifactResolver, resolve_artifact_refs
  19. from cyber_agent.core.task_protocol import ensure_task_protocol, task_contract_ref
  20. class CandidateStateError(ValueError):
  21. pass
  22. class CandidateService:
  23. """Validate, persist, and recover candidate version operations."""
  24. _locks: "WeakValueDictionary[str, asyncio.Lock]" = WeakValueDictionary()
  25. def __init__(
  26. self,
  27. *,
  28. store: Any,
  29. application_binding: Any,
  30. repository: CandidateRepository,
  31. artifact_resolver: ArtifactResolver,
  32. ) -> None:
  33. self.store = store
  34. self.binding = application_binding
  35. self.repository = repository
  36. self.artifact_resolver = artifact_resolver
  37. @classmethod
  38. def _lock_for(cls, root_trace_id: str) -> asyncio.Lock:
  39. lock = cls._locks.get(root_trace_id)
  40. if lock is None:
  41. lock = asyncio.Lock()
  42. cls._locks[root_trace_id] = lock
  43. return lock
  44. async def manage(
  45. self,
  46. trace_id: str,
  47. *,
  48. operation: str,
  49. content: Any,
  50. parent_refs: list[CandidatePointer],
  51. effective_at_sequence: int,
  52. ) -> CandidateRef:
  53. trace, root = await self._require_owner_trace(trace_id)
  54. root_trace_id = root.trace_id
  55. operation_id = f"candidate:{trace_id}:{effective_at_sequence}"
  56. input_payload = {
  57. "operation": operation,
  58. "content": content,
  59. "parent_refs": [item.model_dump(mode="json") for item in parent_refs],
  60. }
  61. input_hash = sha256(
  62. canonical_json(input_payload).encode("utf-8")
  63. ).hexdigest()
  64. async with self._lock_for(root_trace_id):
  65. ledger = await self._load_ledger(root_trace_id)
  66. existing_operation = next(
  67. (
  68. item
  69. for item in ledger.operations
  70. if item.operation_id == operation_id
  71. ),
  72. None,
  73. )
  74. if existing_operation is not None:
  75. if existing_operation.input_hash != input_hash:
  76. raise CandidateStateError(
  77. "Candidate operation payload changed during recovery"
  78. )
  79. if existing_operation.status == "completed":
  80. if existing_operation.candidate is None:
  81. raise CandidateStateError(
  82. "Completed candidate operation has no result"
  83. )
  84. return ledger.candidate(existing_operation.candidate)
  85. if existing_operation.status == "failed":
  86. raise CandidateStateError(
  87. existing_operation.error or "Candidate operation failed"
  88. )
  89. else:
  90. try:
  91. pointer = self._allocate_pointer(
  92. operation,
  93. trace_id,
  94. effective_at_sequence,
  95. parent_refs,
  96. ledger,
  97. )
  98. except ValueError as exc:
  99. raise CandidateStateError(str(exc)) from exc
  100. operation_record = CandidateOperationRecord(
  101. operation_id=operation_id,
  102. operation=operation,
  103. input_hash=input_hash,
  104. candidate=pointer,
  105. )
  106. ledger = ledger.model_copy(update={
  107. "operations": (*ledger.operations, operation_record),
  108. })
  109. await self.store.replace_candidate_ledger(
  110. root_trace_id,
  111. ledger.model_dump(mode="json"),
  112. )
  113. existing_operation = operation_record
  114. assert existing_operation.candidate is not None
  115. pointer = existing_operation.candidate
  116. try:
  117. parents = [ledger.candidate(item) for item in parent_refs]
  118. self._validate_parents(trace, root, parents)
  119. state = ensure_task_protocol(trace.context)
  120. request = CandidateVersionRequest(
  121. operation_id=operation_id,
  122. application_ref=self.binding.application_ref,
  123. root_trace_id=root_trace_id,
  124. owner_trace_id=trace_id,
  125. uid=trace.uid,
  126. candidate_id=pointer.candidate_id,
  127. revision=pointer.revision,
  128. task_contract_ref=task_contract_ref(
  129. state,
  130. root_task_anchor_hash=trace.context.get(
  131. "root_task_anchor_hash"
  132. ),
  133. ),
  134. parent_refs=tuple(parent_refs),
  135. content=content,
  136. )
  137. artifact_ref = ArtifactRef.model_validate(
  138. await self.repository.merge(request)
  139. if operation == "merge"
  140. else await self.repository.put_version(request)
  141. )
  142. materials, issues = await resolve_artifact_refs(
  143. [artifact_ref],
  144. resolver=self.artifact_resolver,
  145. root_trace_id=root_trace_id,
  146. uid=trace.uid,
  147. )
  148. if issues or len(materials) != 1:
  149. reason = issues[0].reason if issues else "Artifact was not resolved"
  150. raise CandidateStateError(
  151. f"CandidateRepository returned an invalid ArtifactRef: {reason}"
  152. )
  153. candidate_ref = CandidateRef(
  154. **pointer.model_dump(mode="json"),
  155. application_ref=self.binding.application_ref,
  156. root_trace_id=root_trace_id,
  157. owner_trace_id=trace_id,
  158. task_contract_ref=request.task_contract_ref,
  159. artifact_ref=artifact_ref,
  160. parent_refs=tuple(parent_refs),
  161. created_at_sequence=effective_at_sequence,
  162. )
  163. except Exception as exc:
  164. failed = self._fail_operation(ledger, operation_id, str(exc))
  165. await self.store.replace_candidate_ledger(
  166. root_trace_id,
  167. failed.model_dump(mode="json"),
  168. )
  169. raise
  170. completed = self._complete_operation(
  171. ledger,
  172. operation_id,
  173. candidate_ref,
  174. )
  175. # If this atomic publish fails after the Repository committed, the
  176. # pending operation remains recoverable. Re-execution uses the same
  177. # operation_id and the Repository's idempotency contract.
  178. await self.store.replace_candidate_ledger(
  179. root_trace_id,
  180. completed.model_dump(mode="json"),
  181. )
  182. return candidate_ref
  183. async def validate_report_refs(
  184. self,
  185. trace_id: str,
  186. refs: list[CandidateRef],
  187. ) -> None:
  188. trace, root = await self._require_owner_trace(trace_id)
  189. async with self._lock_for(root.trace_id):
  190. ledger = await self._load_ledger(root.trace_id)
  191. for ref in refs:
  192. persisted = ledger.candidate(ref)
  193. if persisted != ref:
  194. raise CandidateStateError(
  195. "TaskReport CandidateRef does not match the root ledger"
  196. )
  197. self._validate_candidate_owner(persisted, trace, root)
  198. if ledger.current_state(ref) != "proposed":
  199. raise CandidateStateError(
  200. "TaskReport may only reference proposed candidates"
  201. )
  202. async def _require_owner_trace(self, trace_id: str):
  203. trace = await self.store.get_trace(trace_id)
  204. if trace is None:
  205. raise CandidateStateError(f"Trace not found: {trace_id}")
  206. policy = require_mutable_trace_policy(trace.context)
  207. if not policy.requires_task_progress:
  208. raise CandidateStateError(
  209. "Candidates require Recursive revision 3"
  210. )
  211. application_ref = trace.context.get("application_ref")
  212. if application_ref != self.binding.application_ref.model_dump(mode="json"):
  213. raise CandidateStateError("Candidate Trace application binding mismatch")
  214. root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
  215. root = trace if trace.trace_id == root_trace_id else await self.store.get_trace(
  216. root_trace_id
  217. )
  218. if root is None or root.uid != trace.uid:
  219. raise CandidateStateError("Candidate root or owner mismatch")
  220. if root.context.get("application_ref") != application_ref:
  221. raise CandidateStateError("Candidate root application mismatch")
  222. state = ensure_task_protocol(trace.context)
  223. if state.get("task_report") is not None:
  224. raise CandidateStateError("Candidates cannot change after TaskReport submission")
  225. if state.get("pending_reviews") or state.get("next_actions"):
  226. raise CandidateStateError("Resolve protocol lifecycle work before candidates")
  227. return trace, root
  228. async def _load_ledger(self, root_trace_id: str) -> CandidateLedger:
  229. raw = await self.store.get_candidate_ledger(root_trace_id)
  230. return CandidateLedger.model_validate(raw or {})
  231. def _allocate_pointer(
  232. self,
  233. operation: str,
  234. trace_id: str,
  235. sequence: int,
  236. parents: list[CandidatePointer],
  237. ledger: CandidateLedger,
  238. ) -> CandidatePointer:
  239. if operation == "create":
  240. if parents:
  241. raise CandidateStateError("create does not accept parent_refs")
  242. digest = sha256(f"{trace_id}:{sequence}".encode("utf-8")).hexdigest()
  243. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  244. if operation == "fork":
  245. if len(parents) != 1:
  246. raise CandidateStateError("fork requires exactly one parent")
  247. parent = ledger.candidate(parents[0])
  248. revisions = [
  249. item.revision
  250. for item in ledger.candidates
  251. if item.candidate_id == parent.candidate_id
  252. ]
  253. return CandidatePointer(
  254. candidate_id=parent.candidate_id,
  255. revision=max(revisions) + 1,
  256. )
  257. if operation == "merge":
  258. if len(parents) < 2:
  259. raise CandidateStateError("merge requires at least two parents")
  260. digest = sha256(f"{trace_id}:{sequence}:merge".encode("utf-8")).hexdigest()
  261. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  262. raise CandidateStateError(f"Unsupported candidate operation: {operation}")
  263. def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
  264. for parent in parents:
  265. self._validate_candidate_owner(parent, trace, root)
  266. def _validate_candidate_owner(self, candidate, trace, root) -> None:
  267. if (
  268. candidate.application_ref != self.binding.application_ref
  269. or candidate.root_trace_id != root.trace_id
  270. or candidate.owner_trace_id != trace.trace_id
  271. ):
  272. raise CandidateStateError(
  273. "Candidate belongs to another application, root, or Task"
  274. )
  275. @staticmethod
  276. def _complete_operation(
  277. ledger: CandidateLedger,
  278. operation_id: str,
  279. candidate: CandidateRef,
  280. ) -> CandidateLedger:
  281. operations = tuple(
  282. item.model_copy(update={"status": "completed", "error": None})
  283. if item.operation_id == operation_id
  284. else item
  285. for item in ledger.operations
  286. )
  287. lifecycle = (*ledger.lifecycle, CandidateLifecycleRecord(
  288. operation_id=operation_id,
  289. candidate=CandidatePointer(
  290. candidate_id=candidate.candidate_id,
  291. revision=candidate.revision,
  292. ),
  293. state="proposed",
  294. effective_at_sequence=candidate.created_at_sequence,
  295. ))
  296. return CandidateLedger(
  297. candidates=(*ledger.candidates, candidate),
  298. lifecycle=lifecycle,
  299. operations=operations,
  300. validations=ledger.validations,
  301. )
  302. @staticmethod
  303. def _fail_operation(
  304. ledger: CandidateLedger,
  305. operation_id: str,
  306. error: str,
  307. ) -> CandidateLedger:
  308. operations = tuple(
  309. item.model_copy(update={
  310. "status": "failed",
  311. "error": error[:2_000],
  312. })
  313. if item.operation_id == operation_id
  314. else item
  315. for item in ledger.operations
  316. )
  317. return ledger.model_copy(update={"operations": operations})