| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- """Candidate command service with one root-level consistency boundary."""
- from __future__ import annotations
- import asyncio
- from hashlib import sha256
- from typing import Any
- from weakref import WeakValueDictionary
- from cyber_agent.application.candidate import (
- CandidateLedger,
- CandidateLifecycleRecord,
- CandidateOperationRecord,
- CandidatePointer,
- CandidateRef,
- CandidateRepository,
- CandidateVersionRequest,
- )
- from cyber_agent.application.models import canonical_json
- from cyber_agent.core.agent_mode import require_mutable_trace_policy
- from cyber_agent.core.artifacts import ArtifactRef, ArtifactResolver, resolve_artifact_refs
- from cyber_agent.core.task_protocol import ensure_task_protocol, task_contract_ref
- class CandidateStateError(ValueError):
- pass
- class CandidateService:
- """Validate, persist, and recover candidate version operations."""
- _locks: "WeakValueDictionary[str, asyncio.Lock]" = WeakValueDictionary()
- def __init__(
- self,
- *,
- store: Any,
- application_binding: Any,
- repository: CandidateRepository,
- artifact_resolver: ArtifactResolver,
- ) -> None:
- self.store = store
- self.binding = application_binding
- self.repository = repository
- self.artifact_resolver = artifact_resolver
- @classmethod
- def _lock_for(cls, root_trace_id: str) -> asyncio.Lock:
- lock = cls._locks.get(root_trace_id)
- if lock is None:
- lock = asyncio.Lock()
- cls._locks[root_trace_id] = lock
- return lock
- async def manage(
- self,
- trace_id: str,
- *,
- operation: str,
- content: Any,
- parent_refs: list[CandidatePointer],
- effective_at_sequence: int,
- ) -> CandidateRef:
- trace, root = await self._require_owner_trace(trace_id)
- root_trace_id = root.trace_id
- operation_id = f"candidate:{trace_id}:{effective_at_sequence}"
- input_payload = {
- "operation": operation,
- "content": content,
- "parent_refs": [item.model_dump(mode="json") for item in parent_refs],
- }
- input_hash = sha256(
- canonical_json(input_payload).encode("utf-8")
- ).hexdigest()
- async with self._lock_for(root_trace_id):
- ledger = await self._load_ledger(root_trace_id)
- existing_operation = next(
- (
- item
- for item in ledger.operations
- if item.operation_id == operation_id
- ),
- None,
- )
- if existing_operation is not None:
- if existing_operation.input_hash != input_hash:
- raise CandidateStateError(
- "Candidate operation payload changed during recovery"
- )
- if existing_operation.status == "completed":
- if existing_operation.candidate is None:
- raise CandidateStateError(
- "Completed candidate operation has no result"
- )
- return ledger.candidate(existing_operation.candidate)
- if existing_operation.status == "failed":
- raise CandidateStateError(
- existing_operation.error or "Candidate operation failed"
- )
- else:
- try:
- pointer = self._allocate_pointer(
- operation,
- trace_id,
- effective_at_sequence,
- parent_refs,
- ledger,
- )
- except ValueError as exc:
- raise CandidateStateError(str(exc)) from exc
- operation_record = CandidateOperationRecord(
- operation_id=operation_id,
- operation=operation,
- input_hash=input_hash,
- candidate=pointer,
- )
- ledger = ledger.model_copy(update={
- "operations": (*ledger.operations, operation_record),
- })
- await self.store.replace_candidate_ledger(
- root_trace_id,
- ledger.model_dump(mode="json"),
- )
- existing_operation = operation_record
- assert existing_operation.candidate is not None
- pointer = existing_operation.candidate
- try:
- parents = [ledger.candidate(item) for item in parent_refs]
- self._validate_parents(trace, root, parents)
- state = ensure_task_protocol(trace.context)
- request = CandidateVersionRequest(
- operation_id=operation_id,
- application_ref=self.binding.application_ref,
- root_trace_id=root_trace_id,
- owner_trace_id=trace_id,
- uid=trace.uid,
- candidate_id=pointer.candidate_id,
- revision=pointer.revision,
- task_contract_ref=task_contract_ref(
- state,
- root_task_anchor_hash=trace.context.get(
- "root_task_anchor_hash"
- ),
- ),
- parent_refs=tuple(parent_refs),
- content=content,
- )
- artifact_ref = ArtifactRef.model_validate(
- await self.repository.merge(request)
- if operation == "merge"
- else await self.repository.put_version(request)
- )
- materials, issues = await resolve_artifact_refs(
- [artifact_ref],
- resolver=self.artifact_resolver,
- root_trace_id=root_trace_id,
- uid=trace.uid,
- )
- if issues or len(materials) != 1:
- reason = issues[0].reason if issues else "Artifact was not resolved"
- raise CandidateStateError(
- f"CandidateRepository returned an invalid ArtifactRef: {reason}"
- )
- candidate_ref = CandidateRef(
- **pointer.model_dump(mode="json"),
- application_ref=self.binding.application_ref,
- root_trace_id=root_trace_id,
- owner_trace_id=trace_id,
- task_contract_ref=request.task_contract_ref,
- artifact_ref=artifact_ref,
- parent_refs=tuple(parent_refs),
- created_at_sequence=effective_at_sequence,
- )
- except Exception as exc:
- failed = self._fail_operation(ledger, operation_id, str(exc))
- await self.store.replace_candidate_ledger(
- root_trace_id,
- failed.model_dump(mode="json"),
- )
- raise
- completed = self._complete_operation(
- ledger,
- operation_id,
- candidate_ref,
- )
- # If this atomic publish fails after the Repository committed, the
- # pending operation remains recoverable. Re-execution uses the same
- # operation_id and the Repository's idempotency contract.
- await self.store.replace_candidate_ledger(
- root_trace_id,
- completed.model_dump(mode="json"),
- )
- return candidate_ref
- async def validate_report_refs(
- self,
- trace_id: str,
- refs: list[CandidateRef],
- ) -> None:
- trace, root = await self._require_owner_trace(trace_id)
- async with self._lock_for(root.trace_id):
- ledger = await self._load_ledger(root.trace_id)
- for ref in refs:
- persisted = ledger.candidate(ref)
- if persisted != ref:
- raise CandidateStateError(
- "TaskReport CandidateRef does not match the root ledger"
- )
- self._validate_candidate_owner(persisted, trace, root)
- if ledger.current_state(ref) != "proposed":
- raise CandidateStateError(
- "TaskReport may only reference proposed candidates"
- )
- async def _require_owner_trace(self, trace_id: str):
- trace = await self.store.get_trace(trace_id)
- if trace is None:
- raise CandidateStateError(f"Trace not found: {trace_id}")
- policy = require_mutable_trace_policy(trace.context)
- if not policy.requires_task_progress:
- raise CandidateStateError(
- "Candidates require Recursive revision 3"
- )
- application_ref = trace.context.get("application_ref")
- if application_ref != self.binding.application_ref.model_dump(mode="json"):
- raise CandidateStateError("Candidate Trace application binding mismatch")
- root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
- root = trace if trace.trace_id == root_trace_id else await self.store.get_trace(
- root_trace_id
- )
- if root is None or root.uid != trace.uid:
- raise CandidateStateError("Candidate root or owner mismatch")
- if root.context.get("application_ref") != application_ref:
- raise CandidateStateError("Candidate root application mismatch")
- state = ensure_task_protocol(trace.context)
- if state.get("task_report") is not None:
- raise CandidateStateError("Candidates cannot change after TaskReport submission")
- if state.get("pending_reviews") or state.get("next_actions"):
- raise CandidateStateError("Resolve protocol lifecycle work before candidates")
- return trace, root
- async def _load_ledger(self, root_trace_id: str) -> CandidateLedger:
- raw = await self.store.get_candidate_ledger(root_trace_id)
- return CandidateLedger.model_validate(raw or {})
- def _allocate_pointer(
- self,
- operation: str,
- trace_id: str,
- sequence: int,
- parents: list[CandidatePointer],
- ledger: CandidateLedger,
- ) -> CandidatePointer:
- if operation == "create":
- if parents:
- raise CandidateStateError("create does not accept parent_refs")
- digest = sha256(f"{trace_id}:{sequence}".encode("utf-8")).hexdigest()
- return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
- if operation == "fork":
- if len(parents) != 1:
- raise CandidateStateError("fork requires exactly one parent")
- parent = ledger.candidate(parents[0])
- revisions = [
- item.revision
- for item in ledger.candidates
- if item.candidate_id == parent.candidate_id
- ]
- return CandidatePointer(
- candidate_id=parent.candidate_id,
- revision=max(revisions) + 1,
- )
- if operation == "merge":
- if len(parents) < 2:
- raise CandidateStateError("merge requires at least two parents")
- digest = sha256(f"{trace_id}:{sequence}:merge".encode("utf-8")).hexdigest()
- return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
- raise CandidateStateError(f"Unsupported candidate operation: {operation}")
- def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
- for parent in parents:
- self._validate_candidate_owner(parent, trace, root)
- def _validate_candidate_owner(self, candidate, trace, root) -> None:
- if (
- candidate.application_ref != self.binding.application_ref
- or candidate.root_trace_id != root.trace_id
- or candidate.owner_trace_id != trace.trace_id
- ):
- raise CandidateStateError(
- "Candidate belongs to another application, root, or Task"
- )
- @staticmethod
- def _complete_operation(
- ledger: CandidateLedger,
- operation_id: str,
- candidate: CandidateRef,
- ) -> CandidateLedger:
- operations = tuple(
- item.model_copy(update={"status": "completed", "error": None})
- if item.operation_id == operation_id
- else item
- for item in ledger.operations
- )
- lifecycle = (*ledger.lifecycle, CandidateLifecycleRecord(
- operation_id=operation_id,
- candidate=CandidatePointer(
- candidate_id=candidate.candidate_id,
- revision=candidate.revision,
- ),
- state="proposed",
- effective_at_sequence=candidate.created_at_sequence,
- ))
- return CandidateLedger(
- candidates=(*ledger.candidates, candidate),
- lifecycle=lifecycle,
- operations=operations,
- validations=ledger.validations,
- )
- @staticmethod
- def _fail_operation(
- ledger: CandidateLedger,
- operation_id: str,
- error: str,
- ) -> CandidateLedger:
- operations = tuple(
- item.model_copy(update={
- "status": "failed",
- "error": error[:2_000],
- })
- if item.operation_id == operation_id
- else item
- for item in ledger.operations
- )
- return ledger.model_copy(update={"operations": operations})
|