candidate_service.py 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  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. CandidateRepositoryRejected,
  17. CandidateReviewAction,
  18. CandidateVersionRequest,
  19. MAX_CANDIDATES_PER_ROOT,
  20. MAX_CANDIDATE_OPERATIONS_PER_ROOT,
  21. )
  22. from cyber_agent.application.models import canonical_json
  23. from cyber_agent.application.quality import (
  24. CandidateValidationCheckpoint,
  25. CandidateValidationRecord,
  26. )
  27. from cyber_agent.core.agent_mode import require_mutable_trace_policy
  28. from cyber_agent.core.artifacts import ArtifactRef, ArtifactResolver, resolve_artifact_refs
  29. from cyber_agent.core.task_protocol import ensure_task_protocol, task_contract_ref
  30. from cyber_agent.tools.errors import RecoverableToolExecutionError
  31. class CandidateStateError(ValueError):
  32. pass
  33. class CandidateService:
  34. """Validate, persist, and recover candidate version operations."""
  35. _locks: "WeakValueDictionary[tuple[asyncio.AbstractEventLoop, int, str], asyncio.Lock]" = WeakValueDictionary()
  36. def __init__(
  37. self,
  38. *,
  39. store: Any,
  40. application_binding: Any,
  41. repository: CandidateRepository,
  42. artifact_resolver: ArtifactResolver,
  43. event_service: Any = None,
  44. ) -> None:
  45. self.store = store
  46. self.binding = application_binding
  47. self.repository = repository
  48. self.artifact_resolver = artifact_resolver
  49. self.event_service = event_service
  50. def _lock_for(self, root_trace_id: str) -> asyncio.Lock:
  51. key = (asyncio.get_running_loop(), id(self.store), root_trace_id)
  52. lock = self._locks.get(key)
  53. if lock is None:
  54. lock = asyncio.Lock()
  55. self._locks[key] = lock
  56. return lock
  57. async def has_replayable_version_command(
  58. self,
  59. trace_id: str,
  60. *,
  61. command_id: str,
  62. arguments: dict[str, Any],
  63. ) -> bool:
  64. """Verify the exact durable candidate command behind an orphaned call."""
  65. try:
  66. request = arguments.get("request")
  67. if not isinstance(request, dict):
  68. return False
  69. operation = request.get("operation")
  70. if operation not in {"create", "fork", "merge"}:
  71. return False
  72. parent_refs = [
  73. CandidatePointer.model_validate(item)
  74. for item in request.get("parent_refs", [])
  75. ]
  76. input_hash = sha256(canonical_json({
  77. "operation": operation,
  78. "content": request.get("content"),
  79. "parent_refs": [
  80. item.model_dump(mode="json") for item in parent_refs
  81. ],
  82. }).encode("utf-8")).hexdigest()
  83. command_hash = sha256(canonical_json({
  84. "trace_id": trace_id,
  85. "command_id": command_id,
  86. }).encode("utf-8")).hexdigest()
  87. operation_id = f"candidate:{command_hash}"
  88. _trace, root = await self._require_owner_trace(trace_id)
  89. ledger = await self._load_ledger(root.trace_id)
  90. except Exception:
  91. return False
  92. return any(
  93. item.operation_id == operation_id
  94. and item.command_id == command_id
  95. and item.operation == operation
  96. and item.input_hash == input_hash
  97. and item.status in {"pending", "completed", "failed"}
  98. for item in ledger.operations
  99. )
  100. async def manage(
  101. self,
  102. trace_id: str,
  103. *,
  104. operation: str,
  105. content: Any,
  106. parent_refs: list[CandidatePointer],
  107. effective_at_sequence: int,
  108. command_id: str | None = None,
  109. ) -> CandidateRef:
  110. trace, root = await self._require_owner_trace(trace_id)
  111. root_trace_id = root.trace_id
  112. input_payload = {
  113. "operation": operation,
  114. "content": content,
  115. "parent_refs": [item.model_dump(mode="json") for item in parent_refs],
  116. }
  117. input_hash = sha256(
  118. canonical_json(input_payload).encode("utf-8")
  119. ).hexdigest()
  120. if command_id is not None and (not command_id or len(command_id) > 300):
  121. raise CandidateStateError(
  122. "Candidate command_id must be a non-empty string up to 300 chars"
  123. )
  124. # A persisted tool_call_id identifies one durable command. Direct
  125. # internal callers that do not have an assistant call identity retain
  126. # a sequence-scoped fallback, but the public tool always supplies it.
  127. command_identity = command_id or f"sequence:{effective_at_sequence}"
  128. command_hash = sha256(
  129. canonical_json({
  130. "trace_id": trace_id,
  131. "command_id": command_identity,
  132. }).encode("utf-8")
  133. ).hexdigest()
  134. operation_id = f"candidate:{command_hash}"
  135. async with self._lock_for(root_trace_id):
  136. ledger = await self._load_ledger(root_trace_id)
  137. existing_operation = next(
  138. (
  139. item
  140. for item in ledger.operations
  141. if item.operation_id == operation_id
  142. ),
  143. None,
  144. )
  145. recovering_pending_operation = existing_operation is not None
  146. if existing_operation is not None:
  147. if existing_operation.input_hash != input_hash:
  148. raise CandidateStateError(
  149. "Candidate operation payload changed during recovery"
  150. )
  151. if existing_operation.status == "completed":
  152. if existing_operation.candidate is None:
  153. raise CandidateStateError(
  154. "Completed candidate operation has no result"
  155. )
  156. return ledger.candidate(existing_operation.candidate)
  157. if existing_operation.status == "failed":
  158. raise CandidateStateError(
  159. existing_operation.error or "Candidate operation failed"
  160. )
  161. if self._reserved_candidate_slots(ledger) > MAX_CANDIDATES_PER_ROOT:
  162. raise CandidateStateError(
  163. "Candidate ledger candidate reservations are corrupt"
  164. )
  165. else:
  166. # Every supported version command publishes exactly one new
  167. # immutable CandidateRef. Reject a full aggregate before we
  168. # persist an intent or call the application Repository; doing
  169. # this only in CandidateLedger validation would leave an
  170. # external orphan after the Repository has committed.
  171. if self._reserved_candidate_slots(ledger) >= MAX_CANDIDATES_PER_ROOT:
  172. raise CandidateStateError(
  173. "Candidate ledger candidate limit exceeded before execution"
  174. )
  175. if len(ledger.operations) >= MAX_CANDIDATE_OPERATIONS_PER_ROOT:
  176. raise CandidateStateError(
  177. "Candidate operation limit exceeded before execution"
  178. )
  179. try:
  180. pointer = self._allocate_pointer(
  181. operation,
  182. trace_id,
  183. effective_at_sequence,
  184. parent_refs,
  185. ledger,
  186. )
  187. except ValueError as exc:
  188. raise CandidateStateError(str(exc)) from exc
  189. operation_record = CandidateOperationRecord(
  190. operation_id=operation_id,
  191. command_id=command_id,
  192. operation=operation,
  193. input_hash=input_hash,
  194. candidate=pointer,
  195. )
  196. ledger = ledger.model_copy(update={
  197. "operations": (*ledger.operations, operation_record),
  198. })
  199. await self._persist_ledger(root_trace_id, ledger)
  200. existing_operation = operation_record
  201. assert existing_operation.candidate is not None
  202. pointer = existing_operation.candidate
  203. try:
  204. parents = [ledger.candidate(item) for item in parent_refs]
  205. self._validate_parents(trace, root, parents)
  206. for parent in parents:
  207. await self._assert_candidate_active(ledger, parent)
  208. state = ensure_task_protocol(trace.context)
  209. request = CandidateVersionRequest(
  210. operation_id=operation_id,
  211. application_ref=self.binding.application_ref,
  212. root_trace_id=root_trace_id,
  213. owner_trace_id=trace_id,
  214. uid=trace.uid,
  215. candidate_id=pointer.candidate_id,
  216. revision=pointer.revision,
  217. task_contract_ref=task_contract_ref(
  218. state,
  219. root_task_anchor_hash=trace.context.get(
  220. "root_task_anchor_hash"
  221. ),
  222. ),
  223. parent_refs=tuple(parent_refs),
  224. content=content,
  225. )
  226. try:
  227. raw_artifact_ref = (
  228. await self.repository.merge(request)
  229. if operation == "merge"
  230. else await self.repository.put_version(request)
  231. )
  232. except CandidateRepositoryRejected as exc:
  233. raise CandidateStateError(str(exc)) from exc
  234. except Exception as exc:
  235. raise RecoverableToolExecutionError(
  236. "Candidate Repository execution outcome is unknown; "
  237. "the original idempotent command must be replayed"
  238. ) from exc
  239. try:
  240. artifact_ref = ArtifactRef.model_validate(raw_artifact_ref)
  241. except ValueError as exc:
  242. raise CandidateStateError(
  243. f"CandidateRepository returned an invalid ArtifactRef: {exc}"
  244. ) from exc
  245. materials, issues = await resolve_artifact_refs(
  246. [artifact_ref],
  247. resolver=self.artifact_resolver,
  248. root_trace_id=root_trace_id,
  249. uid=trace.uid,
  250. )
  251. if issues or len(materials) != 1:
  252. reason = issues[0].reason if issues else "Artifact was not resolved"
  253. if any(issue.outcome == "unknown" for issue in issues):
  254. raise RecoverableToolExecutionError(
  255. "Candidate ArtifactResolver is temporarily unavailable; "
  256. "the original command must remain replayable"
  257. )
  258. raise CandidateStateError(
  259. f"CandidateRepository returned an invalid ArtifactRef: {reason}"
  260. )
  261. candidate_ref = CandidateRef(
  262. **pointer.model_dump(mode="json"),
  263. application_ref=self.binding.application_ref,
  264. root_trace_id=root_trace_id,
  265. owner_trace_id=trace_id,
  266. task_contract_ref=request.task_contract_ref,
  267. artifact_ref=artifact_ref,
  268. parent_refs=tuple(parent_refs),
  269. created_at_sequence=effective_at_sequence,
  270. )
  271. except Exception as exc:
  272. if isinstance(exc, RecoverableToolExecutionError):
  273. raise
  274. # A recovered pending intent may already have committed in the
  275. # application Repository. A transient Repository/Resolver error
  276. # cannot prove failure, so keep the original command replayable.
  277. if (
  278. recovering_pending_operation
  279. and not isinstance(exc, CandidateStateError)
  280. ):
  281. raise RecoverableToolExecutionError(
  282. "Candidate recovery was interrupted before its durable "
  283. "ledger outcome could be established"
  284. ) from exc
  285. failed = self._fail_operation(ledger, operation_id, str(exc))
  286. try:
  287. await self._persist_ledger(root_trace_id, failed)
  288. except Exception as publish_exc:
  289. raise RecoverableToolExecutionError(
  290. "Candidate Repository result is durable but its failed "
  291. "ledger outcome must be replayed with the original command ID"
  292. ) from publish_exc
  293. raise
  294. completed = self._complete_operation(
  295. ledger,
  296. operation_id,
  297. candidate_ref,
  298. )
  299. # If this atomic publish fails after the Repository committed, the
  300. # pending operation remains recoverable. Re-execution uses the same
  301. # operation_id and the Repository's idempotency contract.
  302. try:
  303. await self._persist_ledger(root_trace_id, completed)
  304. except Exception as exc:
  305. raise RecoverableToolExecutionError(
  306. "Candidate Repository result is durable but its ledger publish "
  307. "must be replayed with the original command ID"
  308. ) from exc
  309. await self._emit_candidate_registered(candidate_ref, operation_id)
  310. return candidate_ref
  311. async def validate_report_refs(
  312. self,
  313. trace_id: str,
  314. refs: list[CandidateRef],
  315. *,
  316. active_head_sequence: int | None = None,
  317. ) -> None:
  318. trace, root = await self._require_owner_trace(trace_id)
  319. async with self._lock_for(root.trace_id):
  320. ledger = await self._load_ledger(root.trace_id)
  321. for ref in refs:
  322. persisted = ledger.candidate(ref)
  323. if persisted != ref:
  324. raise CandidateStateError(
  325. "TaskReport CandidateRef does not match the root ledger"
  326. )
  327. self._validate_candidate_owner(persisted, trace, root)
  328. await self._assert_candidate_active(
  329. ledger,
  330. persisted,
  331. active_head_sequence=active_head_sequence,
  332. )
  333. if await self._current_state(
  334. ledger,
  335. ref,
  336. active_trace_id=trace_id,
  337. active_head_sequence=active_head_sequence,
  338. ) != "proposed":
  339. raise CandidateStateError(
  340. "TaskReport may only reference proposed candidates"
  341. )
  342. async def resolve_for_validation(
  343. self,
  344. trace_id: str,
  345. candidate_ref: CandidateRef,
  346. ):
  347. """Resolve one exact report-owned candidate through the trusted resolver."""
  348. trace, root = await self._require_bound_trace(trace_id)
  349. async with self._lock_for(root.trace_id):
  350. ledger = await self._load_ledger(root.trace_id)
  351. persisted = ledger.candidate(candidate_ref)
  352. if persisted != candidate_ref:
  353. raise CandidateStateError(
  354. "CandidateRef does not match the root ledger"
  355. )
  356. self._validate_candidate_owner(persisted, trace, root)
  357. await self._assert_candidate_active(ledger, persisted)
  358. materials, issues = await resolve_artifact_refs(
  359. [candidate_ref.artifact_ref],
  360. resolver=self.artifact_resolver,
  361. root_trace_id=root.trace_id,
  362. uid=trace.uid,
  363. )
  364. if issues or len(materials) != 1:
  365. reason = issues[0].reason if issues else "Artifact was not resolved"
  366. raise CandidateStateError(
  367. f"Candidate ArtifactRef cannot be validated: {reason}"
  368. )
  369. return materials[0]
  370. async def cached_validation(
  371. self,
  372. trace_id: str,
  373. candidate_ref: CandidateRef,
  374. plan_hash: str,
  375. ) -> CandidateValidationRecord | None:
  376. trace, root = await self._require_bound_trace(trace_id)
  377. async with self._lock_for(root.trace_id):
  378. ledger = await self._load_ledger(root.trace_id)
  379. persisted = ledger.candidate(candidate_ref)
  380. self._validate_candidate_owner(persisted, trace, root)
  381. await self._assert_candidate_active(ledger, persisted)
  382. for raw in ledger.validations:
  383. record = CandidateValidationRecord.model_validate(raw)
  384. if (
  385. record.candidate_ref == candidate_ref
  386. and record.plan_hash == plan_hash
  387. ):
  388. return record
  389. return None
  390. async def begin_validation_checkpoint(
  391. self,
  392. trace_id: str,
  393. candidate_ref: CandidateRef,
  394. *,
  395. plan: dict[str, Any],
  396. plan_hash: str,
  397. validated_at_sequence: int,
  398. material_usage_recorded: bool,
  399. ) -> CandidateValidationCheckpoint:
  400. trace, root = await self._require_bound_trace(trace_id)
  401. async with self._lock_for(root.trace_id):
  402. ledger = await self._load_ledger(root.trace_id)
  403. persisted = ledger.candidate(candidate_ref)
  404. if persisted != candidate_ref:
  405. raise CandidateStateError(
  406. "Candidate validation references an altered CandidateRef"
  407. )
  408. self._validate_candidate_owner(persisted, trace, root)
  409. await self._assert_candidate_active(ledger, persisted)
  410. for raw in ledger.validation_checkpoints:
  411. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  412. if (
  413. checkpoint.candidate_ref == candidate_ref
  414. and checkpoint.plan_hash == plan_hash
  415. ):
  416. return checkpoint
  417. checkpoint = CandidateValidationCheckpoint(
  418. candidate_ref=candidate_ref,
  419. plan_hash=plan_hash,
  420. validation_plan=plan,
  421. validated_at_sequence=validated_at_sequence,
  422. material_usage_recorded=material_usage_recorded,
  423. )
  424. updated = ledger.model_copy(update={
  425. "validation_checkpoints": (
  426. *ledger.validation_checkpoints,
  427. checkpoint.model_dump(mode="json"),
  428. ),
  429. })
  430. await self._persist_ledger(root.trace_id, updated)
  431. return checkpoint
  432. async def update_validation_checkpoint(
  433. self,
  434. trace_id: str,
  435. candidate_ref: CandidateRef,
  436. plan_hash: str,
  437. **updates: Any,
  438. ) -> CandidateValidationCheckpoint:
  439. mutable_fields = {
  440. "scope_results",
  441. "fixed_checks",
  442. "fixed_scope_errors",
  443. "quality_completed",
  444. "material_usage_recorded",
  445. "aggregate_result",
  446. }
  447. unknown_fields = set(updates) - mutable_fields
  448. if unknown_fields:
  449. raise CandidateStateError(
  450. "Candidate validation checkpoint fields are immutable: "
  451. f"{', '.join(sorted(unknown_fields))}"
  452. )
  453. trace, root = await self._require_bound_trace(trace_id)
  454. async with self._lock_for(root.trace_id):
  455. ledger = await self._load_ledger(root.trace_id)
  456. persisted = ledger.candidate(candidate_ref)
  457. if persisted != candidate_ref:
  458. raise CandidateStateError(
  459. "Candidate validation references an altered CandidateRef"
  460. )
  461. self._validate_candidate_owner(persisted, trace, root)
  462. checkpoints = []
  463. result = None
  464. for raw in ledger.validation_checkpoints:
  465. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  466. if (
  467. checkpoint.candidate_ref == candidate_ref
  468. and checkpoint.plan_hash == plan_hash
  469. ):
  470. checkpoint = CandidateValidationCheckpoint.model_validate({
  471. **checkpoint.model_dump(mode="json"),
  472. **updates,
  473. })
  474. result = checkpoint
  475. checkpoints.append(checkpoint.model_dump(mode="json"))
  476. if result is None:
  477. raise CandidateStateError("Candidate validation checkpoint not found")
  478. updated = ledger.model_copy(update={
  479. "validation_checkpoints": tuple(checkpoints),
  480. })
  481. await self._persist_ledger(root.trace_id, updated)
  482. return result
  483. async def record_validation(
  484. self,
  485. trace_id: str,
  486. record: CandidateValidationRecord,
  487. ) -> None:
  488. trace, root = await self._require_bound_trace(trace_id)
  489. async with self._lock_for(root.trace_id):
  490. ledger = await self._load_ledger(root.trace_id)
  491. persisted = ledger.candidate(record.candidate_ref)
  492. if persisted != record.candidate_ref:
  493. raise CandidateStateError(
  494. "Candidate validation references an altered CandidateRef"
  495. )
  496. self._validate_candidate_owner(persisted, trace, root)
  497. await self._assert_candidate_active(ledger, persisted)
  498. retained = []
  499. for raw in ledger.validations:
  500. item = CandidateValidationRecord.model_validate(raw)
  501. if (
  502. item.candidate_ref == record.candidate_ref
  503. and item.plan_hash == record.plan_hash
  504. ):
  505. continue
  506. retained.append(raw)
  507. updated = ledger.model_copy(update={
  508. "validations": (
  509. *retained,
  510. record.model_dump(mode="json"),
  511. ),
  512. })
  513. checkpoints = []
  514. for raw in updated.validation_checkpoints:
  515. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  516. if (
  517. checkpoint.candidate_ref == record.candidate_ref
  518. and checkpoint.plan_hash == record.plan_hash
  519. ):
  520. result = record.validation_result
  521. checkpoint = checkpoint.model_copy(update={
  522. "scope_results": tuple(result.get("scope_results", [])),
  523. "aggregate_result": result,
  524. })
  525. checkpoints.append(checkpoint.model_dump(mode="json"))
  526. updated = updated.model_copy(update={
  527. "validation_checkpoints": tuple(checkpoints),
  528. })
  529. await self._persist_ledger(root.trace_id, updated)
  530. if self.event_service is not None:
  531. await self.event_service.emit_after_commit(
  532. source_trace_id=record.candidate_ref.owner_trace_id,
  533. event_type="validation.completed",
  534. event_key=(
  535. "validation.completed:candidate:"
  536. f"{record.candidate_ref.candidate_id}:"
  537. f"{record.candidate_ref.revision}:{record.plan_hash}"
  538. ),
  539. effective_at_sequence=record.validated_at_sequence,
  540. payload={
  541. "subject_type": "candidate",
  542. "candidate_ref": record.candidate_ref.model_dump(
  543. mode="json"
  544. ),
  545. "validation_result": record.validation_result,
  546. },
  547. )
  548. async def apply_review_actions(
  549. self,
  550. parent_trace_id: str,
  551. child_trace_id: str,
  552. *,
  553. report_refs: list[CandidateRef],
  554. actions: list[CandidateReviewAction],
  555. effective_at_sequence: int,
  556. command_id: str | None = None,
  557. ) -> list[CandidateLifecycleRecord]:
  558. """Apply parent-owned candidate decisions behind one root lock."""
  559. parent = await self.store.get_trace(parent_trace_id)
  560. child = await self.store.get_trace(child_trace_id)
  561. if parent is None or child is None:
  562. raise CandidateStateError("Parent or child Trace not found")
  563. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  564. raise CandidateStateError("Candidate review requires a direct child")
  565. _parent, root = await self._require_bound_trace(parent_trace_id)
  566. child_root = child.context.get("root_trace_id") or child.trace_id
  567. if (
  568. child_root != root.trace_id
  569. or child.context.get("application_ref")
  570. != self.binding.application_ref.model_dump(mode="json")
  571. ):
  572. raise CandidateStateError("Candidate review application/root mismatch")
  573. report_by_key = {
  574. (item.candidate_id, item.revision): item for item in report_refs
  575. }
  576. action_keys = [
  577. (item.candidate_ref.candidate_id, item.candidate_ref.revision)
  578. for item in actions
  579. ]
  580. if len(action_keys) != len(set(action_keys)):
  581. raise CandidateStateError("Candidate review actions must be unique")
  582. async with self._lock_for(root.trace_id):
  583. ledger = await self._load_ledger(root.trace_id)
  584. batch_operation_id = self._review_batch_operation_id(
  585. parent_trace_id,
  586. child_trace_id,
  587. command_id=command_id,
  588. effective_at_sequence=effective_at_sequence,
  589. )
  590. batch_input_hash = sha256(canonical_json([
  591. item.model_dump(mode="json") for item in actions
  592. ]).encode("utf-8")).hexdigest()
  593. batch_operation = next(
  594. (
  595. item for item in ledger.operations
  596. if item.operation_id == batch_operation_id
  597. ),
  598. None,
  599. )
  600. operations_to_reserve: list[CandidateOperationRecord] = []
  601. if batch_operation is not None:
  602. if batch_operation.input_hash != batch_input_hash:
  603. raise CandidateStateError(
  604. "Candidate review batch payload changed during recovery"
  605. )
  606. else:
  607. batch_operation = CandidateOperationRecord(
  608. operation_id=batch_operation_id,
  609. command_id=command_id,
  610. operation="review_batch",
  611. input_hash=batch_input_hash,
  612. )
  613. operations_to_reserve.append(batch_operation)
  614. existing_operation_ids = {
  615. item.operation_id for item in ledger.operations
  616. }
  617. for action_index, action in enumerate(actions):
  618. action_operation_id = self._review_operation_id(
  619. parent_trace_id,
  620. child_trace_id,
  621. action,
  622. command_id=command_id,
  623. effective_at_sequence=effective_at_sequence,
  624. action_index=action_index,
  625. )
  626. if action_operation_id in existing_operation_ids:
  627. continue
  628. operations_to_reserve.append(CandidateOperationRecord(
  629. operation_id=action_operation_id,
  630. command_id=command_id,
  631. operation=action.action,
  632. input_hash=sha256(
  633. canonical_json(action).encode("utf-8")
  634. ).hexdigest(),
  635. candidate=CandidatePointer(
  636. candidate_id=action.candidate_ref.candidate_id,
  637. revision=action.candidate_ref.revision,
  638. ),
  639. ))
  640. if operations_to_reserve:
  641. if (
  642. len(ledger.operations) + len(operations_to_reserve)
  643. > MAX_CANDIDATE_OPERATIONS_PER_ROOT
  644. ):
  645. raise CandidateStateError(
  646. "Candidate review operation limit exceeded before execution"
  647. )
  648. ledger = ledger.model_copy(update={
  649. "operations": (
  650. *ledger.operations,
  651. *operations_to_reserve,
  652. ),
  653. })
  654. # Persist the ordered batch envelope and every action intent in
  655. # one replacement. Besides freezing completeness, this is the
  656. # durable capacity reservation that keeps later commands from
  657. # stealing slots needed by a crash replay.
  658. await self._persist_ledger(root.trace_id, ledger)
  659. validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
  660. for raw in ledger.validations:
  661. record = CandidateValidationRecord.model_validate(raw)
  662. key = (
  663. record.candidate_ref.candidate_id,
  664. record.candidate_ref.revision,
  665. )
  666. validation_by_key[key] = record
  667. for action_index, (action, key) in enumerate(zip(actions, action_keys)):
  668. if key not in report_by_key or report_by_key[key] != action.candidate_ref:
  669. raise CandidateStateError(
  670. "Candidate action must reference the exact child TaskReport"
  671. )
  672. persisted = ledger.candidate(action.candidate_ref)
  673. if persisted != action.candidate_ref or persisted.owner_trace_id != child_trace_id:
  674. raise CandidateStateError(
  675. "Candidate action references another Task or altered revision"
  676. )
  677. await self._assert_candidate_active(ledger, persisted)
  678. record = validation_by_key.get(key)
  679. if record is None:
  680. raise CandidateStateError(
  681. "Candidate action requires framework validation"
  682. )
  683. from cyber_agent.core.validation import ValidationResult
  684. outcome = ValidationResult.model_validate(
  685. record.validation_result
  686. ).outcome
  687. if action.action == "adopt" and outcome != "passed":
  688. raise CandidateStateError(
  689. "Only a passed candidate revision can be adopted"
  690. )
  691. if action.action == "revise" and outcome == "passed":
  692. raise CandidateStateError(
  693. "Candidate revise requires a non-passed validation"
  694. )
  695. state = await self._current_state(ledger, action.candidate_ref)
  696. expected_operation_id = self._review_operation_id(
  697. parent_trace_id,
  698. child_trace_id,
  699. action,
  700. command_id=command_id,
  701. effective_at_sequence=effective_at_sequence,
  702. action_index=action_index,
  703. )
  704. same_completed_operation = any(
  705. item.operation_id == expected_operation_id
  706. and item.status == "completed"
  707. for item in ledger.operations
  708. )
  709. if (
  710. state not in {"proposed", "adoption_pending"}
  711. and not same_completed_operation
  712. ):
  713. raise CandidateStateError(
  714. f"Candidate already has terminal state: {state}"
  715. )
  716. applied: list[CandidateLifecycleRecord] = []
  717. for action_index, action in enumerate(actions):
  718. ledger, record = await self._apply_review_action(
  719. ledger,
  720. action,
  721. parent_trace_id=parent_trace_id,
  722. child_trace_id=child_trace_id,
  723. root_trace_id=root.trace_id,
  724. effective_at_sequence=effective_at_sequence,
  725. command_id=command_id,
  726. action_index=action_index,
  727. )
  728. applied.append(record)
  729. operations = tuple(
  730. item.model_copy(update={"status": "completed", "error": None})
  731. if item.operation_id == batch_operation_id else item
  732. for item in ledger.operations
  733. )
  734. ledger = ledger.model_copy(update={"operations": operations})
  735. await self._persist_ledger(root.trace_id, ledger)
  736. return applied
  737. async def _apply_review_action(
  738. self,
  739. ledger: CandidateLedger,
  740. action: CandidateReviewAction,
  741. *,
  742. parent_trace_id: str,
  743. child_trace_id: str,
  744. root_trace_id: str,
  745. effective_at_sequence: int,
  746. command_id: str | None,
  747. action_index: int,
  748. ) -> tuple[CandidateLedger, CandidateLifecycleRecord]:
  749. pointer = CandidatePointer(
  750. candidate_id=action.candidate_ref.candidate_id,
  751. revision=action.candidate_ref.revision,
  752. )
  753. operation_id = self._review_operation_id(
  754. parent_trace_id,
  755. child_trace_id,
  756. action,
  757. command_id=command_id,
  758. effective_at_sequence=effective_at_sequence,
  759. action_index=action_index,
  760. )
  761. input_hash = sha256(
  762. canonical_json(action).encode("utf-8")
  763. ).hexdigest()
  764. existing = next(
  765. (item for item in ledger.operations if item.operation_id == operation_id),
  766. None,
  767. )
  768. if existing is not None:
  769. if existing.input_hash != input_hash:
  770. raise CandidateStateError("Candidate review payload changed during recovery")
  771. if existing.status == "completed":
  772. record = next(
  773. item for item in reversed(ledger.lifecycle)
  774. if item.operation_id == operation_id
  775. )
  776. return ledger, record
  777. if existing.status == "failed":
  778. raise CandidateStateError(existing.error or "Candidate review failed")
  779. else:
  780. operation = CandidateOperationRecord(
  781. operation_id=operation_id,
  782. command_id=command_id,
  783. operation=action.action,
  784. input_hash=input_hash,
  785. candidate=pointer,
  786. )
  787. ledger = ledger.model_copy(update={
  788. "operations": (*ledger.operations, operation),
  789. })
  790. if action.action != "adopt":
  791. record = CandidateLifecycleRecord(
  792. operation_id=operation_id,
  793. candidate=pointer,
  794. state="discarded",
  795. effective_at_sequence=effective_at_sequence,
  796. source_trace_id=parent_trace_id,
  797. reason=(
  798. f"retry_from=output: {action.reason}"
  799. if action.action == "revise"
  800. else action.reason
  801. ),
  802. )
  803. ledger = self._finish_review_operation(ledger, operation_id, record)
  804. await self._persist_ledger(root_trace_id, ledger)
  805. await self._emit_lifecycle(record, root_trace_id)
  806. return ledger, record
  807. pending = CandidateLifecycleRecord(
  808. operation_id=operation_id,
  809. candidate=pointer,
  810. state="adoption_pending",
  811. effective_at_sequence=effective_at_sequence,
  812. source_trace_id=parent_trace_id,
  813. reason=action.reason,
  814. )
  815. if ledger.current_state(pointer) != "adoption_pending":
  816. ledger = ledger.model_copy(update={
  817. "lifecycle": (*ledger.lifecycle, pending),
  818. })
  819. await self._persist_ledger(root_trace_id, ledger)
  820. await self._emit_lifecycle(pending, root_trace_id)
  821. try:
  822. receipt = CandidateAdoptionReceipt.model_validate(
  823. await self.repository.commit_adoption(CandidateAdoptionRequest(
  824. operation_id=operation_id,
  825. candidate_ref=action.candidate_ref,
  826. ))
  827. )
  828. if receipt.operation_id != operation_id or not receipt.committed:
  829. raise CandidateStateError("Candidate adoption receipt is invalid")
  830. except Exception as exc:
  831. failed = CandidateLifecycleRecord(
  832. operation_id=operation_id,
  833. candidate=pointer,
  834. state="adoption_failed",
  835. effective_at_sequence=effective_at_sequence,
  836. source_trace_id=parent_trace_id,
  837. reason=str(exc)[:2_000],
  838. )
  839. ledger = self._finish_review_operation(
  840. ledger,
  841. operation_id,
  842. failed,
  843. error=str(exc),
  844. )
  845. await self._persist_ledger(root_trace_id, ledger)
  846. await self._emit_lifecycle(failed, root_trace_id)
  847. raise
  848. adopted = CandidateLifecycleRecord(
  849. operation_id=operation_id,
  850. candidate=pointer,
  851. state="adopted",
  852. effective_at_sequence=effective_at_sequence,
  853. source_trace_id=parent_trace_id,
  854. reason=action.reason,
  855. receipt=receipt,
  856. )
  857. ledger = self._finish_review_operation(ledger, operation_id, adopted)
  858. # A failure here intentionally leaves the durable pending intent. The
  859. # Repository sees the same operation_id when the review is replayed.
  860. await self._persist_ledger(root_trace_id, ledger)
  861. await self._emit_lifecycle(adopted, root_trace_id)
  862. return ledger, adopted
  863. @staticmethod
  864. def _review_operation_id(
  865. parent_trace_id: str,
  866. child_trace_id: str,
  867. action: CandidateReviewAction,
  868. *,
  869. command_id: str | None = None,
  870. effective_at_sequence: int = 0,
  871. action_index: int = 0,
  872. ) -> str:
  873. del action
  874. command_hash = sha256(
  875. canonical_json({
  876. "parent_trace_id": parent_trace_id,
  877. "child_trace_id": child_trace_id,
  878. "command_id": command_id or f"sequence:{effective_at_sequence}",
  879. "action_index": action_index,
  880. }).encode("utf-8")
  881. ).hexdigest()
  882. return f"candidate-review:{command_hash}"
  883. @staticmethod
  884. def _review_batch_operation_id(
  885. parent_trace_id: str,
  886. child_trace_id: str,
  887. *,
  888. command_id: str | None,
  889. effective_at_sequence: int,
  890. ) -> str:
  891. command_hash = sha256(canonical_json({
  892. "parent_trace_id": parent_trace_id,
  893. "child_trace_id": child_trace_id,
  894. "command_id": command_id or f"sequence:{effective_at_sequence}",
  895. }).encode("utf-8")).hexdigest()
  896. return f"candidate-review-batch:{command_hash}"
  897. async def _emit_candidate_registered(
  898. self,
  899. candidate: CandidateRef,
  900. operation_id: str,
  901. ) -> None:
  902. if self.event_service is None:
  903. return
  904. await self.event_service.emit_after_commit(
  905. source_trace_id=candidate.owner_trace_id,
  906. event_type="candidate.version_registered",
  907. event_key=(
  908. f"candidate.version_registered:{candidate.candidate_id}:"
  909. f"{candidate.revision}"
  910. ),
  911. effective_at_sequence=candidate.created_at_sequence,
  912. payload={"candidate_ref": candidate.model_dump(mode="json")},
  913. )
  914. await self.event_service.emit_after_commit(
  915. source_trace_id=candidate.owner_trace_id,
  916. event_type="candidate.lifecycle_changed",
  917. event_key=(
  918. f"candidate.lifecycle_changed:{operation_id}:proposed"
  919. ),
  920. effective_at_sequence=candidate.created_at_sequence,
  921. payload={"lifecycle": CandidateLifecycleRecord(
  922. operation_id=operation_id,
  923. candidate=CandidatePointer(
  924. candidate_id=candidate.candidate_id,
  925. revision=candidate.revision,
  926. ),
  927. state="proposed",
  928. effective_at_sequence=candidate.created_at_sequence,
  929. source_trace_id=candidate.owner_trace_id,
  930. ).model_dump(mode="json")},
  931. )
  932. async def _emit_lifecycle(
  933. self,
  934. lifecycle: CandidateLifecycleRecord,
  935. root_trace_id: str,
  936. ) -> None:
  937. del root_trace_id
  938. if self.event_service is None or lifecycle.source_trace_id is None:
  939. return
  940. await self.event_service.emit_after_commit(
  941. source_trace_id=lifecycle.source_trace_id,
  942. event_type="candidate.lifecycle_changed",
  943. event_key=(
  944. f"candidate.lifecycle_changed:{lifecycle.operation_id}:"
  945. f"{lifecycle.state}"
  946. ),
  947. effective_at_sequence=lifecycle.effective_at_sequence,
  948. payload={"lifecycle": lifecycle.model_dump(mode="json")},
  949. )
  950. @staticmethod
  951. def _finish_review_operation(
  952. ledger: CandidateLedger,
  953. operation_id: str,
  954. lifecycle: CandidateLifecycleRecord,
  955. *,
  956. error: str | None = None,
  957. ) -> CandidateLedger:
  958. operations = tuple(
  959. item.model_copy(update={
  960. "status": "failed" if error else "completed",
  961. "error": error[:2_000] if error else None,
  962. })
  963. if item.operation_id == operation_id else item
  964. for item in ledger.operations
  965. )
  966. return ledger.model_copy(update={
  967. "operations": operations,
  968. "lifecycle": (*ledger.lifecycle, lifecycle),
  969. })
  970. async def assert_rewind_allowed(
  971. self,
  972. trace_id: str,
  973. after_sequence: int,
  974. ) -> None:
  975. trace, root = await self._require_bound_trace(trace_id)
  976. async with self._lock_for(root.trace_id):
  977. ledger = await self._load_ledger(root.trace_id)
  978. for lifecycle in ledger.lifecycle:
  979. if lifecycle.state != "adopted":
  980. continue
  981. candidate = ledger.candidate(lifecycle.candidate)
  982. ancestor_ids: set[str] = set()
  983. for start_trace_id in {
  984. lifecycle.source_trace_id,
  985. candidate.owner_trace_id,
  986. }:
  987. current_trace_id = start_trace_id
  988. while current_trace_id and current_trace_id not in ancestor_ids:
  989. ancestor_ids.add(current_trace_id)
  990. current = await self.store.get_trace(current_trace_id)
  991. current_trace_id = (
  992. current.parent_trace_id if current is not None else None
  993. )
  994. crosses_review = (
  995. lifecycle.source_trace_id == trace_id
  996. and lifecycle.effective_at_sequence > after_sequence
  997. )
  998. crosses_version = (
  999. candidate.owner_trace_id == trace_id
  1000. and candidate.created_at_sequence > after_sequence
  1001. )
  1002. incomparable_ancestor = (
  1003. trace_id in ancestor_ids
  1004. and trace_id not in {
  1005. lifecycle.source_trace_id,
  1006. candidate.owner_trace_id,
  1007. }
  1008. )
  1009. if crosses_review or crosses_version or incomparable_ancestor:
  1010. raise CandidateStateError(
  1011. "Cannot rewind across a committed candidate adoption"
  1012. )
  1013. @staticmethod
  1014. def _candidate_operation(
  1015. ledger: CandidateLedger,
  1016. candidate: CandidateRef,
  1017. ) -> CandidateOperationRecord:
  1018. for operation in ledger.operations:
  1019. if (
  1020. operation.operation in {"create", "fork", "merge"}
  1021. and operation.candidate is not None
  1022. and operation.candidate.candidate_id == candidate.candidate_id
  1023. and operation.candidate.revision == candidate.revision
  1024. ):
  1025. return operation
  1026. raise CandidateStateError(
  1027. "Candidate has no persisted creation command"
  1028. )
  1029. async def _sequence_is_active(
  1030. self,
  1031. trace_id: str,
  1032. sequence: int,
  1033. *,
  1034. command_id: str | None,
  1035. allow_untracked: bool,
  1036. head_sequence: int | None = None,
  1037. ) -> bool:
  1038. trace = await self.store.get_trace(trace_id)
  1039. if trace is None:
  1040. return False
  1041. all_messages = await self.store.get_trace_messages(trace_id)
  1042. if not all_messages:
  1043. # CandidateService has a direct internal port for tests and
  1044. # controlled adapters. Production tool commands are always bound
  1045. # to a persisted assistant tool_call_id and never take this path.
  1046. return allow_untracked
  1047. path = await self.store.get_main_path_messages(
  1048. trace_id,
  1049. trace.head_sequence if head_sequence is None else head_sequence,
  1050. )
  1051. message = next((item for item in path if item.sequence == sequence), None)
  1052. if message is None:
  1053. return False
  1054. if command_id is not None:
  1055. return message.role == "tool" and message.tool_call_id == command_id
  1056. return True
  1057. async def _assert_candidate_active(
  1058. self,
  1059. ledger: CandidateLedger,
  1060. candidate: CandidateRef,
  1061. *,
  1062. active_head_sequence: int | None = None,
  1063. ) -> None:
  1064. operation = self._candidate_operation(ledger, candidate)
  1065. if not await self._sequence_is_active(
  1066. candidate.owner_trace_id,
  1067. candidate.created_at_sequence,
  1068. command_id=operation.command_id,
  1069. allow_untracked=operation.command_id is None,
  1070. head_sequence=active_head_sequence,
  1071. ):
  1072. raise CandidateStateError(
  1073. "Candidate is not on the current owner Trace branch"
  1074. )
  1075. async def _current_state(
  1076. self,
  1077. ledger: CandidateLedger,
  1078. pointer: CandidatePointer,
  1079. *,
  1080. active_trace_id: str | None = None,
  1081. active_head_sequence: int | None = None,
  1082. ) -> str | None:
  1083. candidate = ledger.candidate(pointer)
  1084. creation = self._candidate_operation(ledger, candidate)
  1085. state = None
  1086. operations = {item.operation_id: item for item in ledger.operations}
  1087. for lifecycle in ledger.lifecycle:
  1088. if (
  1089. lifecycle.candidate.candidate_id != pointer.candidate_id
  1090. or lifecycle.candidate.revision != pointer.revision
  1091. or lifecycle.source_trace_id is None
  1092. ):
  1093. continue
  1094. operation = operations.get(lifecycle.operation_id)
  1095. command_id = operation.command_id if operation is not None else None
  1096. if await self._sequence_is_active(
  1097. lifecycle.source_trace_id,
  1098. lifecycle.effective_at_sequence,
  1099. command_id=command_id,
  1100. allow_untracked=creation.command_id is None,
  1101. head_sequence=(
  1102. active_head_sequence
  1103. if lifecycle.source_trace_id == active_trace_id
  1104. else None
  1105. ),
  1106. ):
  1107. state = lifecycle.state
  1108. return state
  1109. async def _require_owner_trace(self, trace_id: str):
  1110. trace, root = await self._require_bound_trace(trace_id)
  1111. state = ensure_task_protocol(trace.context)
  1112. if state.get("task_report") is not None:
  1113. raise CandidateStateError("Candidates cannot change after TaskReport submission")
  1114. if state.get("pending_reviews") or state.get("next_actions"):
  1115. raise CandidateStateError("Resolve protocol lifecycle work before candidates")
  1116. return trace, root
  1117. async def _require_bound_trace(self, trace_id: str):
  1118. trace = await self.store.get_trace(trace_id)
  1119. if trace is None:
  1120. raise CandidateStateError(f"Trace not found: {trace_id}")
  1121. policy = require_mutable_trace_policy(trace.context)
  1122. if not policy.requires_task_progress:
  1123. raise CandidateStateError(
  1124. "Candidates require Recursive revision 3"
  1125. )
  1126. application_ref = trace.context.get("application_ref")
  1127. if application_ref != self.binding.application_ref.model_dump(mode="json"):
  1128. raise CandidateStateError("Candidate Trace application binding mismatch")
  1129. root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
  1130. root = trace if trace.trace_id == root_trace_id else await self.store.get_trace(
  1131. root_trace_id
  1132. )
  1133. if root is None or root.uid != trace.uid:
  1134. raise CandidateStateError("Candidate root or owner mismatch")
  1135. if root.context.get("application_ref") != application_ref:
  1136. raise CandidateStateError("Candidate root application mismatch")
  1137. return trace, root
  1138. async def _load_ledger(self, root_trace_id: str) -> CandidateLedger:
  1139. raw = await self.store.get_candidate_ledger(root_trace_id)
  1140. return CandidateLedger.model_validate(raw or {})
  1141. async def _persist_ledger(
  1142. self,
  1143. root_trace_id: str,
  1144. ledger: CandidateLedger,
  1145. ) -> None:
  1146. """Validate the complete immutable aggregate before atomic replacement."""
  1147. validated = CandidateLedger.model_validate(
  1148. ledger.model_dump(mode="json")
  1149. )
  1150. await self.store.replace_candidate_ledger(
  1151. root_trace_id,
  1152. validated.model_dump(mode="json"),
  1153. )
  1154. def _allocate_pointer(
  1155. self,
  1156. operation: str,
  1157. trace_id: str,
  1158. sequence: int,
  1159. parents: list[CandidatePointer],
  1160. ledger: CandidateLedger,
  1161. ) -> CandidatePointer:
  1162. if operation == "create":
  1163. if parents:
  1164. raise CandidateStateError("create does not accept parent_refs")
  1165. digest = sha256(f"{trace_id}:{sequence}".encode("utf-8")).hexdigest()
  1166. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  1167. if operation == "fork":
  1168. if len(parents) != 1:
  1169. raise CandidateStateError("fork requires exactly one parent")
  1170. parent = ledger.candidate(parents[0])
  1171. revisions = [
  1172. item.revision
  1173. for item in ledger.candidates
  1174. if item.candidate_id == parent.candidate_id
  1175. ]
  1176. return CandidatePointer(
  1177. candidate_id=parent.candidate_id,
  1178. revision=max(revisions) + 1,
  1179. )
  1180. if operation == "merge":
  1181. if len(parents) < 2:
  1182. raise CandidateStateError("merge requires at least two parents")
  1183. digest = sha256(f"{trace_id}:{sequence}:merge".encode("utf-8")).hexdigest()
  1184. return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
  1185. raise CandidateStateError(f"Unsupported candidate operation: {operation}")
  1186. @staticmethod
  1187. def _reserved_candidate_slots(ledger: CandidateLedger) -> int:
  1188. """Count published revisions plus recoverable version intents.
  1189. A pending create/fork/merge owns its eventual aggregate slot. Counting
  1190. the reservation prevents a later command from consuming the final slot
  1191. and making the earlier Repository side effect impossible to finalize.
  1192. """
  1193. pending_versions = sum(
  1194. item.status == "pending"
  1195. and item.operation in {"create", "fork", "merge"}
  1196. for item in ledger.operations
  1197. )
  1198. return len(ledger.candidates) + pending_versions
  1199. def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
  1200. for parent in parents:
  1201. self._validate_candidate_owner(parent, trace, root)
  1202. def _validate_candidate_owner(self, candidate, trace, root) -> None:
  1203. if (
  1204. candidate.application_ref != self.binding.application_ref
  1205. or candidate.root_trace_id != root.trace_id
  1206. or candidate.owner_trace_id != trace.trace_id
  1207. ):
  1208. raise CandidateStateError(
  1209. "Candidate belongs to another application, root, or Task"
  1210. )
  1211. @staticmethod
  1212. def _complete_operation(
  1213. ledger: CandidateLedger,
  1214. operation_id: str,
  1215. candidate: CandidateRef,
  1216. ) -> CandidateLedger:
  1217. operations = tuple(
  1218. item.model_copy(update={"status": "completed", "error": None})
  1219. if item.operation_id == operation_id
  1220. else item
  1221. for item in ledger.operations
  1222. )
  1223. lifecycle = (*ledger.lifecycle, CandidateLifecycleRecord(
  1224. operation_id=operation_id,
  1225. candidate=CandidatePointer(
  1226. candidate_id=candidate.candidate_id,
  1227. revision=candidate.revision,
  1228. ),
  1229. state="proposed",
  1230. effective_at_sequence=candidate.created_at_sequence,
  1231. source_trace_id=candidate.owner_trace_id,
  1232. ))
  1233. return CandidateLedger(
  1234. candidates=(*ledger.candidates, candidate),
  1235. lifecycle=lifecycle,
  1236. operations=operations,
  1237. validations=ledger.validations,
  1238. validation_checkpoints=ledger.validation_checkpoints,
  1239. )
  1240. @staticmethod
  1241. def _fail_operation(
  1242. ledger: CandidateLedger,
  1243. operation_id: str,
  1244. error: str,
  1245. ) -> CandidateLedger:
  1246. operations = tuple(
  1247. item.model_copy(update={
  1248. "status": "failed",
  1249. "error": error[:2_000],
  1250. })
  1251. if item.operation_id == operation_id
  1252. else item
  1253. for item in ledger.operations
  1254. )
  1255. return ledger.model_copy(update={"operations": operations})