candidate_service.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  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. CandidateAdoptionReceipt,
  9. CandidateAdoptionRequest,
  10. CandidateLedger,
  11. CandidateLifecycleRecord,
  12. CandidateOperationRecord,
  13. CandidatePointer,
  14. CandidateRef,
  15. CandidateRepository,
  16. CandidateReviewAction,
  17. CandidateVersionRequest,
  18. )
  19. from cyber_agent.application.models import canonical_json
  20. from cyber_agent.application.quality import (
  21. CandidateValidationCheckpoint,
  22. CandidateValidationRecord,
  23. )
  24. from cyber_agent.core.agent_mode import require_mutable_trace_policy
  25. from cyber_agent.core.artifacts import ArtifactRef, ArtifactResolver, resolve_artifact_refs
  26. from cyber_agent.core.task_protocol import ensure_task_protocol, task_contract_ref
  27. class CandidateStateError(ValueError):
  28. pass
  29. class CandidateService:
  30. """Validate, persist, and recover candidate version operations."""
  31. _locks: "WeakValueDictionary[str, asyncio.Lock]" = WeakValueDictionary()
  32. def __init__(
  33. self,
  34. *,
  35. store: Any,
  36. application_binding: Any,
  37. repository: CandidateRepository,
  38. artifact_resolver: ArtifactResolver,
  39. event_service: Any = None,
  40. ) -> None:
  41. self.store = store
  42. self.binding = application_binding
  43. self.repository = repository
  44. self.artifact_resolver = artifact_resolver
  45. self.event_service = event_service
  46. @classmethod
  47. def _lock_for(cls, root_trace_id: str) -> asyncio.Lock:
  48. lock = cls._locks.get(root_trace_id)
  49. if lock is None:
  50. lock = asyncio.Lock()
  51. cls._locks[root_trace_id] = lock
  52. return lock
  53. async def manage(
  54. self,
  55. trace_id: str,
  56. *,
  57. operation: str,
  58. content: Any,
  59. parent_refs: list[CandidatePointer],
  60. effective_at_sequence: int,
  61. ) -> CandidateRef:
  62. trace, root = await self._require_owner_trace(trace_id)
  63. root_trace_id = root.trace_id
  64. input_payload = {
  65. "operation": operation,
  66. "content": content,
  67. "parent_refs": [item.model_dump(mode="json") for item in parent_refs],
  68. }
  69. input_hash = sha256(
  70. canonical_json(input_payload).encode("utf-8")
  71. ).hexdigest()
  72. # The command identity must survive a process crash and a subsequent
  73. # model retry. Sequence numbers identify attempts, not durable intent.
  74. command_hash = sha256(
  75. canonical_json({
  76. "trace_id": trace_id,
  77. "input_hash": input_hash,
  78. }).encode("utf-8")
  79. ).hexdigest()
  80. operation_id = f"candidate:{command_hash}"
  81. async with self._lock_for(root_trace_id):
  82. ledger = await self._load_ledger(root_trace_id)
  83. existing_operation = next(
  84. (
  85. item
  86. for item in ledger.operations
  87. if item.operation_id == operation_id
  88. ),
  89. None,
  90. )
  91. if existing_operation is not None:
  92. if existing_operation.input_hash != input_hash:
  93. raise CandidateStateError(
  94. "Candidate operation payload changed during recovery"
  95. )
  96. if existing_operation.status == "completed":
  97. if existing_operation.candidate is None:
  98. raise CandidateStateError(
  99. "Completed candidate operation has no result"
  100. )
  101. return ledger.candidate(existing_operation.candidate)
  102. if existing_operation.status == "failed":
  103. raise CandidateStateError(
  104. existing_operation.error or "Candidate operation failed"
  105. )
  106. else:
  107. try:
  108. pointer = self._allocate_pointer(
  109. operation,
  110. trace_id,
  111. effective_at_sequence,
  112. parent_refs,
  113. ledger,
  114. )
  115. except ValueError as exc:
  116. raise CandidateStateError(str(exc)) from exc
  117. operation_record = CandidateOperationRecord(
  118. operation_id=operation_id,
  119. operation=operation,
  120. input_hash=input_hash,
  121. candidate=pointer,
  122. )
  123. ledger = ledger.model_copy(update={
  124. "operations": (*ledger.operations, operation_record),
  125. })
  126. await self.store.replace_candidate_ledger(
  127. root_trace_id,
  128. ledger.model_dump(mode="json"),
  129. )
  130. existing_operation = operation_record
  131. assert existing_operation.candidate is not None
  132. pointer = existing_operation.candidate
  133. try:
  134. parents = [ledger.candidate(item) for item in parent_refs]
  135. self._validate_parents(trace, root, parents)
  136. state = ensure_task_protocol(trace.context)
  137. request = CandidateVersionRequest(
  138. operation_id=operation_id,
  139. application_ref=self.binding.application_ref,
  140. root_trace_id=root_trace_id,
  141. owner_trace_id=trace_id,
  142. uid=trace.uid,
  143. candidate_id=pointer.candidate_id,
  144. revision=pointer.revision,
  145. task_contract_ref=task_contract_ref(
  146. state,
  147. root_task_anchor_hash=trace.context.get(
  148. "root_task_anchor_hash"
  149. ),
  150. ),
  151. parent_refs=tuple(parent_refs),
  152. content=content,
  153. )
  154. artifact_ref = ArtifactRef.model_validate(
  155. await self.repository.merge(request)
  156. if operation == "merge"
  157. else await self.repository.put_version(request)
  158. )
  159. materials, issues = await resolve_artifact_refs(
  160. [artifact_ref],
  161. resolver=self.artifact_resolver,
  162. root_trace_id=root_trace_id,
  163. uid=trace.uid,
  164. )
  165. if issues or len(materials) != 1:
  166. reason = issues[0].reason if issues else "Artifact was not resolved"
  167. raise CandidateStateError(
  168. f"CandidateRepository returned an invalid ArtifactRef: {reason}"
  169. )
  170. candidate_ref = CandidateRef(
  171. **pointer.model_dump(mode="json"),
  172. application_ref=self.binding.application_ref,
  173. root_trace_id=root_trace_id,
  174. owner_trace_id=trace_id,
  175. task_contract_ref=request.task_contract_ref,
  176. artifact_ref=artifact_ref,
  177. parent_refs=tuple(parent_refs),
  178. created_at_sequence=effective_at_sequence,
  179. )
  180. except Exception as exc:
  181. failed = self._fail_operation(ledger, operation_id, str(exc))
  182. await self.store.replace_candidate_ledger(
  183. root_trace_id,
  184. failed.model_dump(mode="json"),
  185. )
  186. raise
  187. completed = self._complete_operation(
  188. ledger,
  189. operation_id,
  190. candidate_ref,
  191. )
  192. # If this atomic publish fails after the Repository committed, the
  193. # pending operation remains recoverable. Re-execution uses the same
  194. # operation_id and the Repository's idempotency contract.
  195. await self.store.replace_candidate_ledger(
  196. root_trace_id,
  197. completed.model_dump(mode="json"),
  198. )
  199. await self._emit_candidate_registered(candidate_ref, operation_id)
  200. return candidate_ref
  201. async def validate_report_refs(
  202. self,
  203. trace_id: str,
  204. refs: list[CandidateRef],
  205. ) -> None:
  206. trace, root = await self._require_owner_trace(trace_id)
  207. async with self._lock_for(root.trace_id):
  208. ledger = await self._load_ledger(root.trace_id)
  209. for ref in refs:
  210. persisted = ledger.candidate(ref)
  211. if persisted != ref:
  212. raise CandidateStateError(
  213. "TaskReport CandidateRef does not match the root ledger"
  214. )
  215. self._validate_candidate_owner(persisted, trace, root)
  216. if ledger.current_state(ref) != "proposed":
  217. raise CandidateStateError(
  218. "TaskReport may only reference proposed candidates"
  219. )
  220. async def resolve_for_validation(
  221. self,
  222. trace_id: str,
  223. candidate_ref: CandidateRef,
  224. ):
  225. """Resolve one exact report-owned candidate through the trusted resolver."""
  226. trace, root = await self._require_bound_trace(trace_id)
  227. async with self._lock_for(root.trace_id):
  228. ledger = await self._load_ledger(root.trace_id)
  229. persisted = ledger.candidate(candidate_ref)
  230. if persisted != candidate_ref:
  231. raise CandidateStateError(
  232. "CandidateRef does not match the root ledger"
  233. )
  234. self._validate_candidate_owner(persisted, trace, root)
  235. materials, issues = await resolve_artifact_refs(
  236. [candidate_ref.artifact_ref],
  237. resolver=self.artifact_resolver,
  238. root_trace_id=root.trace_id,
  239. uid=trace.uid,
  240. )
  241. if issues or len(materials) != 1:
  242. reason = issues[0].reason if issues else "Artifact was not resolved"
  243. raise CandidateStateError(
  244. f"Candidate ArtifactRef cannot be validated: {reason}"
  245. )
  246. return materials[0]
  247. async def cached_validation(
  248. self,
  249. trace_id: str,
  250. candidate_ref: CandidateRef,
  251. plan_hash: str,
  252. ) -> CandidateValidationRecord | None:
  253. trace, root = await self._require_bound_trace(trace_id)
  254. async with self._lock_for(root.trace_id):
  255. ledger = await self._load_ledger(root.trace_id)
  256. persisted = ledger.candidate(candidate_ref)
  257. self._validate_candidate_owner(persisted, trace, root)
  258. for raw in ledger.validations:
  259. record = CandidateValidationRecord.model_validate(raw)
  260. if (
  261. record.candidate_ref == candidate_ref
  262. and record.plan_hash == plan_hash
  263. ):
  264. return record
  265. return None
  266. async def begin_validation_checkpoint(
  267. self,
  268. trace_id: str,
  269. candidate_ref: CandidateRef,
  270. *,
  271. plan: dict[str, Any],
  272. plan_hash: str,
  273. validated_at_sequence: int,
  274. material_usage_recorded: bool,
  275. ) -> CandidateValidationCheckpoint:
  276. trace, root = await self._require_bound_trace(trace_id)
  277. async with self._lock_for(root.trace_id):
  278. ledger = await self._load_ledger(root.trace_id)
  279. persisted = ledger.candidate(candidate_ref)
  280. if persisted != candidate_ref:
  281. raise CandidateStateError(
  282. "Candidate validation references an altered CandidateRef"
  283. )
  284. self._validate_candidate_owner(persisted, trace, root)
  285. for raw in ledger.validation_checkpoints:
  286. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  287. if (
  288. checkpoint.candidate_ref == candidate_ref
  289. and checkpoint.plan_hash == plan_hash
  290. ):
  291. return checkpoint
  292. checkpoint = CandidateValidationCheckpoint(
  293. candidate_ref=candidate_ref,
  294. plan_hash=plan_hash,
  295. validation_plan=plan,
  296. validated_at_sequence=validated_at_sequence,
  297. material_usage_recorded=material_usage_recorded,
  298. )
  299. updated = ledger.model_copy(update={
  300. "validation_checkpoints": (
  301. *ledger.validation_checkpoints,
  302. checkpoint.model_dump(mode="json"),
  303. ),
  304. })
  305. await self.store.replace_candidate_ledger(
  306. root.trace_id,
  307. updated.model_dump(mode="json"),
  308. )
  309. return checkpoint
  310. async def update_validation_checkpoint(
  311. self,
  312. trace_id: str,
  313. candidate_ref: CandidateRef,
  314. plan_hash: str,
  315. **updates: Any,
  316. ) -> CandidateValidationCheckpoint:
  317. trace, root = await self._require_bound_trace(trace_id)
  318. async with self._lock_for(root.trace_id):
  319. ledger = await self._load_ledger(root.trace_id)
  320. persisted = ledger.candidate(candidate_ref)
  321. if persisted != candidate_ref:
  322. raise CandidateStateError(
  323. "Candidate validation references an altered CandidateRef"
  324. )
  325. self._validate_candidate_owner(persisted, trace, root)
  326. checkpoints = []
  327. result = None
  328. for raw in ledger.validation_checkpoints:
  329. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  330. if (
  331. checkpoint.candidate_ref == candidate_ref
  332. and checkpoint.plan_hash == plan_hash
  333. ):
  334. checkpoint = checkpoint.model_copy(update=updates)
  335. result = checkpoint
  336. checkpoints.append(checkpoint.model_dump(mode="json"))
  337. if result is None:
  338. raise CandidateStateError("Candidate validation checkpoint not found")
  339. updated = ledger.model_copy(update={
  340. "validation_checkpoints": tuple(checkpoints),
  341. })
  342. await self.store.replace_candidate_ledger(
  343. root.trace_id,
  344. updated.model_dump(mode="json"),
  345. )
  346. return result
  347. async def record_validation(
  348. self,
  349. trace_id: str,
  350. record: CandidateValidationRecord,
  351. ) -> None:
  352. trace, root = await self._require_bound_trace(trace_id)
  353. async with self._lock_for(root.trace_id):
  354. ledger = await self._load_ledger(root.trace_id)
  355. persisted = ledger.candidate(record.candidate_ref)
  356. if persisted != record.candidate_ref:
  357. raise CandidateStateError(
  358. "Candidate validation references an altered CandidateRef"
  359. )
  360. self._validate_candidate_owner(persisted, trace, root)
  361. retained = []
  362. for raw in ledger.validations:
  363. item = CandidateValidationRecord.model_validate(raw)
  364. if (
  365. item.candidate_ref == record.candidate_ref
  366. and item.plan_hash == record.plan_hash
  367. ):
  368. continue
  369. retained.append(raw)
  370. updated = ledger.model_copy(update={
  371. "validations": (
  372. *retained,
  373. record.model_dump(mode="json"),
  374. ),
  375. })
  376. checkpoints = []
  377. for raw in updated.validation_checkpoints:
  378. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  379. if (
  380. checkpoint.candidate_ref == record.candidate_ref
  381. and checkpoint.plan_hash == record.plan_hash
  382. ):
  383. result = record.validation_result
  384. checkpoint = checkpoint.model_copy(update={
  385. "scope_results": tuple(result.get("scope_results", [])),
  386. "aggregate_result": result,
  387. })
  388. checkpoints.append(checkpoint.model_dump(mode="json"))
  389. updated = updated.model_copy(update={
  390. "validation_checkpoints": tuple(checkpoints),
  391. })
  392. await self.store.replace_candidate_ledger(
  393. root.trace_id,
  394. updated.model_dump(mode="json"),
  395. )
  396. if self.event_service is not None:
  397. await self.event_service.emit_after_commit(
  398. source_trace_id=record.candidate_ref.owner_trace_id,
  399. event_type="validation.completed",
  400. event_key=(
  401. "validation.completed:candidate:"
  402. f"{record.candidate_ref.candidate_id}:"
  403. f"{record.candidate_ref.revision}:{record.plan_hash}"
  404. ),
  405. effective_at_sequence=record.validated_at_sequence,
  406. payload={
  407. "subject_type": "candidate",
  408. "candidate_ref": record.candidate_ref.model_dump(
  409. mode="json"
  410. ),
  411. "validation_result": record.validation_result,
  412. },
  413. )
  414. async def apply_review_actions(
  415. self,
  416. parent_trace_id: str,
  417. child_trace_id: str,
  418. *,
  419. report_refs: list[CandidateRef],
  420. actions: list[CandidateReviewAction],
  421. effective_at_sequence: int,
  422. ) -> list[CandidateLifecycleRecord]:
  423. """Apply parent-owned candidate decisions behind one root lock."""
  424. if not actions:
  425. return []
  426. parent = await self.store.get_trace(parent_trace_id)
  427. child = await self.store.get_trace(child_trace_id)
  428. if parent is None or child is None:
  429. raise CandidateStateError("Parent or child Trace not found")
  430. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  431. raise CandidateStateError("Candidate review requires a direct child")
  432. _parent, root = await self._require_bound_trace(parent_trace_id)
  433. child_root = child.context.get("root_trace_id") or child.trace_id
  434. if (
  435. child_root != root.trace_id
  436. or child.context.get("application_ref")
  437. != self.binding.application_ref.model_dump(mode="json")
  438. ):
  439. raise CandidateStateError("Candidate review application/root mismatch")
  440. report_by_key = {
  441. (item.candidate_id, item.revision): item for item in report_refs
  442. }
  443. action_keys = [
  444. (item.candidate_ref.candidate_id, item.candidate_ref.revision)
  445. for item in actions
  446. ]
  447. if len(action_keys) != len(set(action_keys)):
  448. raise CandidateStateError("Candidate review actions must be unique")
  449. async with self._lock_for(root.trace_id):
  450. ledger = await self._load_ledger(root.trace_id)
  451. validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
  452. for raw in ledger.validations:
  453. record = CandidateValidationRecord.model_validate(raw)
  454. key = (
  455. record.candidate_ref.candidate_id,
  456. record.candidate_ref.revision,
  457. )
  458. validation_by_key[key] = record
  459. for action, key in zip(actions, action_keys):
  460. if key not in report_by_key or report_by_key[key] != action.candidate_ref:
  461. raise CandidateStateError(
  462. "Candidate action must reference the exact child TaskReport"
  463. )
  464. persisted = ledger.candidate(action.candidate_ref)
  465. if persisted != action.candidate_ref or persisted.owner_trace_id != child_trace_id:
  466. raise CandidateStateError(
  467. "Candidate action references another Task or altered revision"
  468. )
  469. record = validation_by_key.get(key)
  470. if record is None:
  471. raise CandidateStateError(
  472. "Candidate action requires framework validation"
  473. )
  474. from cyber_agent.core.validation import ValidationResult
  475. outcome = ValidationResult.model_validate(
  476. record.validation_result
  477. ).outcome
  478. if action.action == "adopt" and outcome != "passed":
  479. raise CandidateStateError(
  480. "Only a passed candidate revision can be adopted"
  481. )
  482. if action.action == "revise" and outcome == "passed":
  483. raise CandidateStateError(
  484. "Candidate revise requires a non-passed validation"
  485. )
  486. state = ledger.current_state(action.candidate_ref)
  487. expected_operation_id = self._review_operation_id(
  488. parent_trace_id,
  489. child_trace_id,
  490. action,
  491. )
  492. same_completed_operation = any(
  493. item.operation_id == expected_operation_id
  494. and item.status == "completed"
  495. for item in ledger.operations
  496. )
  497. if (
  498. state not in {"proposed", "adoption_pending"}
  499. and not same_completed_operation
  500. ):
  501. raise CandidateStateError(
  502. f"Candidate already has terminal state: {state}"
  503. )
  504. applied: list[CandidateLifecycleRecord] = []
  505. for action in actions:
  506. ledger, record = await self._apply_review_action(
  507. ledger,
  508. action,
  509. parent_trace_id=parent_trace_id,
  510. child_trace_id=child_trace_id,
  511. root_trace_id=root.trace_id,
  512. effective_at_sequence=effective_at_sequence,
  513. )
  514. applied.append(record)
  515. return applied
  516. async def _apply_review_action(
  517. self,
  518. ledger: CandidateLedger,
  519. action: CandidateReviewAction,
  520. *,
  521. parent_trace_id: str,
  522. child_trace_id: str,
  523. root_trace_id: str,
  524. effective_at_sequence: int,
  525. ) -> tuple[CandidateLedger, CandidateLifecycleRecord]:
  526. pointer = CandidatePointer(
  527. candidate_id=action.candidate_ref.candidate_id,
  528. revision=action.candidate_ref.revision,
  529. )
  530. operation_id = self._review_operation_id(
  531. parent_trace_id,
  532. child_trace_id,
  533. action,
  534. )
  535. input_hash = sha256(
  536. canonical_json(action).encode("utf-8")
  537. ).hexdigest()
  538. existing = next(
  539. (item for item in ledger.operations if item.operation_id == operation_id),
  540. None,
  541. )
  542. if existing is not None:
  543. if existing.input_hash != input_hash:
  544. raise CandidateStateError("Candidate review payload changed during recovery")
  545. if existing.status == "completed":
  546. record = next(
  547. item for item in reversed(ledger.lifecycle)
  548. if item.operation_id == operation_id
  549. )
  550. return ledger, record
  551. if existing.status == "failed":
  552. raise CandidateStateError(existing.error or "Candidate review failed")
  553. else:
  554. operation = CandidateOperationRecord(
  555. operation_id=operation_id,
  556. operation=action.action,
  557. input_hash=input_hash,
  558. candidate=pointer,
  559. )
  560. ledger = ledger.model_copy(update={
  561. "operations": (*ledger.operations, operation),
  562. })
  563. if action.action != "adopt":
  564. record = CandidateLifecycleRecord(
  565. operation_id=operation_id,
  566. candidate=pointer,
  567. state="discarded",
  568. effective_at_sequence=effective_at_sequence,
  569. source_trace_id=parent_trace_id,
  570. reason=(
  571. f"retry_from=output: {action.reason}"
  572. if action.action == "revise"
  573. else action.reason
  574. ),
  575. )
  576. ledger = self._finish_review_operation(ledger, operation_id, record)
  577. await self.store.replace_candidate_ledger(
  578. root_trace_id,
  579. ledger.model_dump(mode="json"),
  580. )
  581. await self._emit_lifecycle(record, root_trace_id)
  582. return ledger, record
  583. pending = CandidateLifecycleRecord(
  584. operation_id=operation_id,
  585. candidate=pointer,
  586. state="adoption_pending",
  587. effective_at_sequence=effective_at_sequence,
  588. source_trace_id=parent_trace_id,
  589. reason=action.reason,
  590. )
  591. if ledger.current_state(pointer) != "adoption_pending":
  592. ledger = ledger.model_copy(update={
  593. "lifecycle": (*ledger.lifecycle, pending),
  594. })
  595. await self.store.replace_candidate_ledger(
  596. root_trace_id,
  597. ledger.model_dump(mode="json"),
  598. )
  599. await self._emit_lifecycle(pending, root_trace_id)
  600. try:
  601. receipt = CandidateAdoptionReceipt.model_validate(
  602. await self.repository.commit_adoption(CandidateAdoptionRequest(
  603. operation_id=operation_id,
  604. candidate_ref=action.candidate_ref,
  605. ))
  606. )
  607. if receipt.operation_id != operation_id or not receipt.committed:
  608. raise CandidateStateError("Candidate adoption receipt is invalid")
  609. except Exception as exc:
  610. failed = CandidateLifecycleRecord(
  611. operation_id=operation_id,
  612. candidate=pointer,
  613. state="adoption_failed",
  614. effective_at_sequence=effective_at_sequence,
  615. source_trace_id=parent_trace_id,
  616. reason=str(exc)[:2_000],
  617. )
  618. ledger = self._finish_review_operation(
  619. ledger,
  620. operation_id,
  621. failed,
  622. error=str(exc),
  623. )
  624. await self.store.replace_candidate_ledger(
  625. root_trace_id,
  626. ledger.model_dump(mode="json"),
  627. )
  628. await self._emit_lifecycle(failed, root_trace_id)
  629. raise
  630. adopted = CandidateLifecycleRecord(
  631. operation_id=operation_id,
  632. candidate=pointer,
  633. state="adopted",
  634. effective_at_sequence=effective_at_sequence,
  635. source_trace_id=parent_trace_id,
  636. reason=action.reason,
  637. receipt=receipt,
  638. )
  639. ledger = self._finish_review_operation(ledger, operation_id, adopted)
  640. # A failure here intentionally leaves the durable pending intent. The
  641. # Repository sees the same operation_id when the review is replayed.
  642. await self.store.replace_candidate_ledger(
  643. root_trace_id,
  644. ledger.model_dump(mode="json"),
  645. )
  646. await self._emit_lifecycle(adopted, root_trace_id)
  647. return ledger, adopted
  648. @staticmethod
  649. def _review_operation_id(
  650. parent_trace_id: str,
  651. child_trace_id: str,
  652. action: CandidateReviewAction,
  653. ) -> str:
  654. action_hash = sha256(
  655. canonical_json(action).encode("utf-8")
  656. ).hexdigest()
  657. command_hash = sha256(
  658. canonical_json({
  659. "parent_trace_id": parent_trace_id,
  660. "child_trace_id": child_trace_id,
  661. "action_hash": action_hash,
  662. }).encode("utf-8")
  663. ).hexdigest()
  664. return f"candidate-review:{command_hash}"
  665. async def _emit_candidate_registered(
  666. self,
  667. candidate: CandidateRef,
  668. operation_id: str,
  669. ) -> None:
  670. if self.event_service is None:
  671. return
  672. await self.event_service.emit_after_commit(
  673. source_trace_id=candidate.owner_trace_id,
  674. event_type="candidate.version_registered",
  675. event_key=(
  676. f"candidate.version_registered:{candidate.candidate_id}:"
  677. f"{candidate.revision}"
  678. ),
  679. effective_at_sequence=candidate.created_at_sequence,
  680. payload={"candidate_ref": candidate.model_dump(mode="json")},
  681. )
  682. await self.event_service.emit_after_commit(
  683. source_trace_id=candidate.owner_trace_id,
  684. event_type="candidate.lifecycle_changed",
  685. event_key=(
  686. f"candidate.lifecycle_changed:{operation_id}:proposed"
  687. ),
  688. effective_at_sequence=candidate.created_at_sequence,
  689. payload={"lifecycle": CandidateLifecycleRecord(
  690. operation_id=operation_id,
  691. candidate=CandidatePointer(
  692. candidate_id=candidate.candidate_id,
  693. revision=candidate.revision,
  694. ),
  695. state="proposed",
  696. effective_at_sequence=candidate.created_at_sequence,
  697. source_trace_id=candidate.owner_trace_id,
  698. ).model_dump(mode="json")},
  699. )
  700. async def _emit_lifecycle(
  701. self,
  702. lifecycle: CandidateLifecycleRecord,
  703. root_trace_id: str,
  704. ) -> None:
  705. del root_trace_id
  706. if self.event_service is None or lifecycle.source_trace_id is None:
  707. return
  708. await self.event_service.emit_after_commit(
  709. source_trace_id=lifecycle.source_trace_id,
  710. event_type="candidate.lifecycle_changed",
  711. event_key=(
  712. f"candidate.lifecycle_changed:{lifecycle.operation_id}:"
  713. f"{lifecycle.state}"
  714. ),
  715. effective_at_sequence=lifecycle.effective_at_sequence,
  716. payload={"lifecycle": lifecycle.model_dump(mode="json")},
  717. )
  718. @staticmethod
  719. def _finish_review_operation(
  720. ledger: CandidateLedger,
  721. operation_id: str,
  722. lifecycle: CandidateLifecycleRecord,
  723. *,
  724. error: str | None = None,
  725. ) -> CandidateLedger:
  726. operations = tuple(
  727. item.model_copy(update={
  728. "status": "failed" if error else "completed",
  729. "error": error[:2_000] if error else None,
  730. })
  731. if item.operation_id == operation_id else item
  732. for item in ledger.operations
  733. )
  734. return ledger.model_copy(update={
  735. "operations": operations,
  736. "lifecycle": (*ledger.lifecycle, lifecycle),
  737. })
  738. async def assert_rewind_allowed(
  739. self,
  740. trace_id: str,
  741. after_sequence: int,
  742. ) -> None:
  743. trace, root = await self._require_bound_trace(trace_id)
  744. async with self._lock_for(root.trace_id):
  745. ledger = await self._load_ledger(root.trace_id)
  746. for lifecycle in ledger.lifecycle:
  747. if lifecycle.state != "adopted":
  748. continue
  749. candidate = ledger.candidate(lifecycle.candidate)
  750. ancestor_ids: set[str] = set()
  751. for start_trace_id in {
  752. lifecycle.source_trace_id,
  753. candidate.owner_trace_id,
  754. }:
  755. current_trace_id = start_trace_id
  756. while current_trace_id and current_trace_id not in ancestor_ids:
  757. ancestor_ids.add(current_trace_id)
  758. current = await self.store.get_trace(current_trace_id)
  759. current_trace_id = (
  760. current.parent_trace_id if current is not None else None
  761. )
  762. crosses_review = (
  763. lifecycle.source_trace_id == trace_id
  764. and lifecycle.effective_at_sequence > after_sequence
  765. )
  766. crosses_version = (
  767. candidate.owner_trace_id == trace_id
  768. and candidate.created_at_sequence > after_sequence
  769. )
  770. incomparable_ancestor = (
  771. trace_id in ancestor_ids
  772. and trace_id not in {
  773. lifecycle.source_trace_id,
  774. candidate.owner_trace_id,
  775. }
  776. )
  777. if crosses_review or crosses_version or incomparable_ancestor:
  778. raise CandidateStateError(
  779. "Cannot rewind across a committed candidate adoption"
  780. )
  781. async def _require_owner_trace(self, trace_id: str):
  782. trace, root = await self._require_bound_trace(trace_id)
  783. state = ensure_task_protocol(trace.context)
  784. if state.get("task_report") is not None:
  785. raise CandidateStateError("Candidates cannot change after TaskReport submission")
  786. if state.get("pending_reviews") or state.get("next_actions"):
  787. raise CandidateStateError("Resolve protocol lifecycle work before candidates")
  788. return trace, root
  789. async def _require_bound_trace(self, trace_id: str):
  790. trace = await self.store.get_trace(trace_id)
  791. if trace is None:
  792. raise CandidateStateError(f"Trace not found: {trace_id}")
  793. policy = require_mutable_trace_policy(trace.context)
  794. if not policy.requires_task_progress:
  795. raise CandidateStateError(
  796. "Candidates require Recursive revision 3"
  797. )
  798. application_ref = trace.context.get("application_ref")
  799. if application_ref != self.binding.application_ref.model_dump(mode="json"):
  800. raise CandidateStateError("Candidate Trace application binding mismatch")
  801. root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
  802. root = trace if trace.trace_id == root_trace_id else await self.store.get_trace(
  803. root_trace_id
  804. )
  805. if root is None or root.uid != trace.uid:
  806. raise CandidateStateError("Candidate root or owner mismatch")
  807. if root.context.get("application_ref") != application_ref:
  808. raise CandidateStateError("Candidate root application mismatch")
  809. return trace, root
  810. async def _load_ledger(self, root_trace_id: str) -> CandidateLedger:
  811. raw = await self.store.get_candidate_ledger(root_trace_id)
  812. return CandidateLedger.model_validate(raw or {})
  813. def _allocate_pointer(
  814. self,
  815. operation: str,
  816. trace_id: str,
  817. sequence: int,
  818. parents: list[CandidatePointer],
  819. ledger: CandidateLedger,
  820. ) -> CandidatePointer:
  821. if operation == "create":
  822. if parents:
  823. raise CandidateStateError("create does not accept parent_refs")
  824. digest = sha256(f"{trace_id}:{sequence}".encode("utf-8")).hexdigest()
  825. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  826. if operation == "fork":
  827. if len(parents) != 1:
  828. raise CandidateStateError("fork requires exactly one parent")
  829. parent = ledger.candidate(parents[0])
  830. revisions = [
  831. item.revision
  832. for item in ledger.candidates
  833. if item.candidate_id == parent.candidate_id
  834. ]
  835. return CandidatePointer(
  836. candidate_id=parent.candidate_id,
  837. revision=max(revisions) + 1,
  838. )
  839. if operation == "merge":
  840. if len(parents) < 2:
  841. raise CandidateStateError("merge requires at least two parents")
  842. digest = sha256(f"{trace_id}:{sequence}:merge".encode("utf-8")).hexdigest()
  843. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  844. raise CandidateStateError(f"Unsupported candidate operation: {operation}")
  845. def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
  846. for parent in parents:
  847. self._validate_candidate_owner(parent, trace, root)
  848. def _validate_candidate_owner(self, candidate, trace, root) -> None:
  849. if (
  850. candidate.application_ref != self.binding.application_ref
  851. or candidate.root_trace_id != root.trace_id
  852. or candidate.owner_trace_id != trace.trace_id
  853. ):
  854. raise CandidateStateError(
  855. "Candidate belongs to another application, root, or Task"
  856. )
  857. @staticmethod
  858. def _complete_operation(
  859. ledger: CandidateLedger,
  860. operation_id: str,
  861. candidate: CandidateRef,
  862. ) -> CandidateLedger:
  863. operations = tuple(
  864. item.model_copy(update={"status": "completed", "error": None})
  865. if item.operation_id == operation_id
  866. else item
  867. for item in ledger.operations
  868. )
  869. lifecycle = (*ledger.lifecycle, CandidateLifecycleRecord(
  870. operation_id=operation_id,
  871. candidate=CandidatePointer(
  872. candidate_id=candidate.candidate_id,
  873. revision=candidate.revision,
  874. ),
  875. state="proposed",
  876. effective_at_sequence=candidate.created_at_sequence,
  877. source_trace_id=candidate.owner_trace_id,
  878. ))
  879. return CandidateLedger(
  880. candidates=(*ledger.candidates, candidate),
  881. lifecycle=lifecycle,
  882. operations=operations,
  883. validations=ledger.validations,
  884. validation_checkpoints=ledger.validation_checkpoints,
  885. )
  886. @staticmethod
  887. def _fail_operation(
  888. ledger: CandidateLedger,
  889. operation_id: str,
  890. error: str,
  891. ) -> CandidateLedger:
  892. operations = tuple(
  893. item.model_copy(update={
  894. "status": "failed",
  895. "error": error[:2_000],
  896. })
  897. if item.operation_id == operation_id
  898. else item
  899. for item in ledger.operations
  900. )
  901. return ledger.model_copy(update={"operations": operations})