candidate_service.py 32 KB

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