|
|
@@ -8,15 +8,19 @@ from typing import Any
|
|
|
from weakref import WeakValueDictionary
|
|
|
|
|
|
from cyber_agent.application.candidate import (
|
|
|
+ CandidateAdoptionReceipt,
|
|
|
+ CandidateAdoptionRequest,
|
|
|
CandidateLedger,
|
|
|
CandidateLifecycleRecord,
|
|
|
CandidateOperationRecord,
|
|
|
CandidatePointer,
|
|
|
CandidateRef,
|
|
|
CandidateRepository,
|
|
|
+ CandidateReviewAction,
|
|
|
CandidateVersionRequest,
|
|
|
)
|
|
|
from cyber_agent.application.models import canonical_json
|
|
|
+from cyber_agent.application.quality import CandidateValidationRecord
|
|
|
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
|
|
|
@@ -213,7 +217,375 @@ class CandidateService:
|
|
|
"TaskReport may only reference proposed candidates"
|
|
|
)
|
|
|
|
|
|
+ async def resolve_for_validation(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ candidate_ref: CandidateRef,
|
|
|
+ ):
|
|
|
+ """Resolve one exact report-owned candidate through the trusted resolver."""
|
|
|
+ trace, root = await self._require_bound_trace(trace_id)
|
|
|
+ async with self._lock_for(root.trace_id):
|
|
|
+ ledger = await self._load_ledger(root.trace_id)
|
|
|
+ persisted = ledger.candidate(candidate_ref)
|
|
|
+ if persisted != candidate_ref:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "CandidateRef does not match the root ledger"
|
|
|
+ )
|
|
|
+ self._validate_candidate_owner(persisted, trace, root)
|
|
|
+ materials, issues = await resolve_artifact_refs(
|
|
|
+ [candidate_ref.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"Candidate ArtifactRef cannot be validated: {reason}"
|
|
|
+ )
|
|
|
+ return materials[0]
|
|
|
+
|
|
|
+ async def cached_validation(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ candidate_ref: CandidateRef,
|
|
|
+ plan_hash: str,
|
|
|
+ ) -> CandidateValidationRecord | None:
|
|
|
+ trace, root = await self._require_bound_trace(trace_id)
|
|
|
+ async with self._lock_for(root.trace_id):
|
|
|
+ ledger = await self._load_ledger(root.trace_id)
|
|
|
+ persisted = ledger.candidate(candidate_ref)
|
|
|
+ self._validate_candidate_owner(persisted, trace, root)
|
|
|
+ for raw in ledger.validations:
|
|
|
+ record = CandidateValidationRecord.model_validate(raw)
|
|
|
+ if (
|
|
|
+ record.candidate_ref == candidate_ref
|
|
|
+ and record.plan_hash == plan_hash
|
|
|
+ ):
|
|
|
+ return record
|
|
|
+ return None
|
|
|
+
|
|
|
+ async def record_validation(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ record: CandidateValidationRecord,
|
|
|
+ ) -> None:
|
|
|
+ trace, root = await self._require_bound_trace(trace_id)
|
|
|
+ async with self._lock_for(root.trace_id):
|
|
|
+ ledger = await self._load_ledger(root.trace_id)
|
|
|
+ persisted = ledger.candidate(record.candidate_ref)
|
|
|
+ if persisted != record.candidate_ref:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Candidate validation references an altered CandidateRef"
|
|
|
+ )
|
|
|
+ self._validate_candidate_owner(persisted, trace, root)
|
|
|
+ retained = tuple(
|
|
|
+ raw for raw in ledger.validations
|
|
|
+ if not (
|
|
|
+ CandidateValidationRecord.model_validate(raw).candidate_ref
|
|
|
+ == record.candidate_ref
|
|
|
+ and CandidateValidationRecord.model_validate(raw).plan_hash
|
|
|
+ == record.plan_hash
|
|
|
+ )
|
|
|
+ )
|
|
|
+ updated = ledger.model_copy(update={
|
|
|
+ "validations": (
|
|
|
+ *retained,
|
|
|
+ record.model_dump(mode="json"),
|
|
|
+ ),
|
|
|
+ })
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
+ root.trace_id,
|
|
|
+ updated.model_dump(mode="json"),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def apply_review_actions(
|
|
|
+ self,
|
|
|
+ parent_trace_id: str,
|
|
|
+ child_trace_id: str,
|
|
|
+ *,
|
|
|
+ report_refs: list[CandidateRef],
|
|
|
+ actions: list[CandidateReviewAction],
|
|
|
+ effective_at_sequence: int,
|
|
|
+ ) -> list[CandidateLifecycleRecord]:
|
|
|
+ """Apply parent-owned candidate decisions behind one root lock."""
|
|
|
+ if not actions:
|
|
|
+ return []
|
|
|
+ parent = await self.store.get_trace(parent_trace_id)
|
|
|
+ child = await self.store.get_trace(child_trace_id)
|
|
|
+ if parent is None or child is None:
|
|
|
+ raise CandidateStateError("Parent or child Trace not found")
|
|
|
+ if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
|
|
|
+ raise CandidateStateError("Candidate review requires a direct child")
|
|
|
+ _parent, root = await self._require_bound_trace(parent_trace_id)
|
|
|
+ child_root = child.context.get("root_trace_id") or child.trace_id
|
|
|
+ if (
|
|
|
+ child_root != root.trace_id
|
|
|
+ or child.context.get("application_ref")
|
|
|
+ != self.binding.application_ref.model_dump(mode="json")
|
|
|
+ ):
|
|
|
+ raise CandidateStateError("Candidate review application/root mismatch")
|
|
|
+ report_by_key = {
|
|
|
+ (item.candidate_id, item.revision): item for item in report_refs
|
|
|
+ }
|
|
|
+ action_keys = [
|
|
|
+ (item.candidate_ref.candidate_id, item.candidate_ref.revision)
|
|
|
+ for item in actions
|
|
|
+ ]
|
|
|
+ if len(action_keys) != len(set(action_keys)):
|
|
|
+ raise CandidateStateError("Candidate review actions must be unique")
|
|
|
+ async with self._lock_for(root.trace_id):
|
|
|
+ ledger = await self._load_ledger(root.trace_id)
|
|
|
+ validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
|
|
|
+ for raw in ledger.validations:
|
|
|
+ record = CandidateValidationRecord.model_validate(raw)
|
|
|
+ key = (
|
|
|
+ record.candidate_ref.candidate_id,
|
|
|
+ record.candidate_ref.revision,
|
|
|
+ )
|
|
|
+ validation_by_key[key] = record
|
|
|
+ for action, key in zip(actions, action_keys):
|
|
|
+ if key not in report_by_key or report_by_key[key] != action.candidate_ref:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Candidate action must reference the exact child TaskReport"
|
|
|
+ )
|
|
|
+ persisted = ledger.candidate(action.candidate_ref)
|
|
|
+ if persisted != action.candidate_ref or persisted.owner_trace_id != child_trace_id:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Candidate action references another Task or altered revision"
|
|
|
+ )
|
|
|
+ record = validation_by_key.get(key)
|
|
|
+ if record is None:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Candidate action requires framework validation"
|
|
|
+ )
|
|
|
+ from cyber_agent.core.validation import ValidationResult
|
|
|
+
|
|
|
+ outcome = ValidationResult.model_validate(
|
|
|
+ record.validation_result
|
|
|
+ ).outcome
|
|
|
+ if action.action == "adopt" and outcome != "passed":
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Only a passed candidate revision can be adopted"
|
|
|
+ )
|
|
|
+ if action.action == "revise" and outcome == "passed":
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Candidate revise requires a non-passed validation"
|
|
|
+ )
|
|
|
+ state = ledger.current_state(action.candidate_ref)
|
|
|
+ expected_operation_id = (
|
|
|
+ f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
|
|
|
+ f"{key[0]}:{key[1]}:{action.action}"
|
|
|
+ )
|
|
|
+ same_completed_operation = any(
|
|
|
+ item.operation_id == expected_operation_id
|
|
|
+ and item.status == "completed"
|
|
|
+ for item in ledger.operations
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ state not in {"proposed", "adoption_pending"}
|
|
|
+ and not same_completed_operation
|
|
|
+ ):
|
|
|
+ raise CandidateStateError(
|
|
|
+ f"Candidate already has terminal state: {state}"
|
|
|
+ )
|
|
|
+
|
|
|
+ applied: list[CandidateLifecycleRecord] = []
|
|
|
+ for action in actions:
|
|
|
+ ledger, record = await self._apply_review_action(
|
|
|
+ ledger,
|
|
|
+ action,
|
|
|
+ parent_trace_id=parent_trace_id,
|
|
|
+ root_trace_id=root.trace_id,
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
+ )
|
|
|
+ applied.append(record)
|
|
|
+ return applied
|
|
|
+
|
|
|
+ async def _apply_review_action(
|
|
|
+ self,
|
|
|
+ ledger: CandidateLedger,
|
|
|
+ action: CandidateReviewAction,
|
|
|
+ *,
|
|
|
+ parent_trace_id: str,
|
|
|
+ root_trace_id: str,
|
|
|
+ effective_at_sequence: int,
|
|
|
+ ) -> tuple[CandidateLedger, CandidateLifecycleRecord]:
|
|
|
+ pointer = CandidatePointer(
|
|
|
+ candidate_id=action.candidate_ref.candidate_id,
|
|
|
+ revision=action.candidate_ref.revision,
|
|
|
+ )
|
|
|
+ operation_id = (
|
|
|
+ f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
|
|
|
+ f"{pointer.candidate_id}:{pointer.revision}:{action.action}"
|
|
|
+ )
|
|
|
+ input_hash = sha256(
|
|
|
+ canonical_json(action).encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ existing = next(
|
|
|
+ (item for item in ledger.operations if item.operation_id == operation_id),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ if existing.input_hash != input_hash:
|
|
|
+ raise CandidateStateError("Candidate review payload changed during recovery")
|
|
|
+ if existing.status == "completed":
|
|
|
+ record = next(
|
|
|
+ item for item in reversed(ledger.lifecycle)
|
|
|
+ if item.operation_id == operation_id
|
|
|
+ )
|
|
|
+ return ledger, record
|
|
|
+ if existing.status == "failed":
|
|
|
+ raise CandidateStateError(existing.error or "Candidate review failed")
|
|
|
+ else:
|
|
|
+ operation = CandidateOperationRecord(
|
|
|
+ operation_id=operation_id,
|
|
|
+ operation=action.action,
|
|
|
+ input_hash=input_hash,
|
|
|
+ candidate=pointer,
|
|
|
+ )
|
|
|
+ ledger = ledger.model_copy(update={
|
|
|
+ "operations": (*ledger.operations, operation),
|
|
|
+ })
|
|
|
+ if action.action != "adopt":
|
|
|
+ record = CandidateLifecycleRecord(
|
|
|
+ operation_id=operation_id,
|
|
|
+ candidate=pointer,
|
|
|
+ state="discarded",
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
+ source_trace_id=parent_trace_id,
|
|
|
+ reason=(
|
|
|
+ f"retry_from=output: {action.reason}"
|
|
|
+ if action.action == "revise"
|
|
|
+ else action.reason
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ ledger = self._finish_review_operation(ledger, operation_id, record)
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
+ root_trace_id,
|
|
|
+ ledger.model_dump(mode="json"),
|
|
|
+ )
|
|
|
+ return ledger, record
|
|
|
+
|
|
|
+ pending = CandidateLifecycleRecord(
|
|
|
+ operation_id=operation_id,
|
|
|
+ candidate=pointer,
|
|
|
+ state="adoption_pending",
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
+ source_trace_id=parent_trace_id,
|
|
|
+ reason=action.reason,
|
|
|
+ )
|
|
|
+ if ledger.current_state(pointer) != "adoption_pending":
|
|
|
+ ledger = ledger.model_copy(update={
|
|
|
+ "lifecycle": (*ledger.lifecycle, pending),
|
|
|
+ })
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
+ root_trace_id,
|
|
|
+ ledger.model_dump(mode="json"),
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ receipt = CandidateAdoptionReceipt.model_validate(
|
|
|
+ await self.repository.commit_adoption(CandidateAdoptionRequest(
|
|
|
+ operation_id=operation_id,
|
|
|
+ candidate_ref=action.candidate_ref,
|
|
|
+ ))
|
|
|
+ )
|
|
|
+ if receipt.operation_id != operation_id or not receipt.committed:
|
|
|
+ raise CandidateStateError("Candidate adoption receipt is invalid")
|
|
|
+ except Exception as exc:
|
|
|
+ failed = CandidateLifecycleRecord(
|
|
|
+ operation_id=operation_id,
|
|
|
+ candidate=pointer,
|
|
|
+ state="adoption_failed",
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
+ source_trace_id=parent_trace_id,
|
|
|
+ reason=str(exc)[:2_000],
|
|
|
+ )
|
|
|
+ ledger = self._finish_review_operation(
|
|
|
+ ledger,
|
|
|
+ operation_id,
|
|
|
+ failed,
|
|
|
+ error=str(exc),
|
|
|
+ )
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
+ root_trace_id,
|
|
|
+ ledger.model_dump(mode="json"),
|
|
|
+ )
|
|
|
+ raise
|
|
|
+ adopted = CandidateLifecycleRecord(
|
|
|
+ operation_id=operation_id,
|
|
|
+ candidate=pointer,
|
|
|
+ state="adopted",
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
+ source_trace_id=parent_trace_id,
|
|
|
+ reason=action.reason,
|
|
|
+ receipt=receipt,
|
|
|
+ )
|
|
|
+ ledger = self._finish_review_operation(ledger, operation_id, adopted)
|
|
|
+ # A failure here intentionally leaves the durable pending intent. The
|
|
|
+ # Repository sees the same operation_id when the review is replayed.
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
+ root_trace_id,
|
|
|
+ ledger.model_dump(mode="json"),
|
|
|
+ )
|
|
|
+ return ledger, adopted
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _finish_review_operation(
|
|
|
+ ledger: CandidateLedger,
|
|
|
+ operation_id: str,
|
|
|
+ lifecycle: CandidateLifecycleRecord,
|
|
|
+ *,
|
|
|
+ error: str | None = None,
|
|
|
+ ) -> CandidateLedger:
|
|
|
+ operations = tuple(
|
|
|
+ item.model_copy(update={
|
|
|
+ "status": "failed" if error else "completed",
|
|
|
+ "error": error[:2_000] if error else None,
|
|
|
+ })
|
|
|
+ if item.operation_id == operation_id else item
|
|
|
+ for item in ledger.operations
|
|
|
+ )
|
|
|
+ return ledger.model_copy(update={
|
|
|
+ "operations": operations,
|
|
|
+ "lifecycle": (*ledger.lifecycle, lifecycle),
|
|
|
+ })
|
|
|
+
|
|
|
+ async def assert_rewind_allowed(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ after_sequence: int,
|
|
|
+ ) -> None:
|
|
|
+ trace, root = await self._require_bound_trace(trace_id)
|
|
|
+ async with self._lock_for(root.trace_id):
|
|
|
+ ledger = await self._load_ledger(root.trace_id)
|
|
|
+ for lifecycle in ledger.lifecycle:
|
|
|
+ if lifecycle.state != "adopted":
|
|
|
+ continue
|
|
|
+ candidate = ledger.candidate(lifecycle.candidate)
|
|
|
+ crosses_review = (
|
|
|
+ lifecycle.source_trace_id == trace_id
|
|
|
+ and lifecycle.effective_at_sequence > after_sequence
|
|
|
+ )
|
|
|
+ crosses_version = (
|
|
|
+ candidate.owner_trace_id == trace_id
|
|
|
+ and candidate.created_at_sequence > after_sequence
|
|
|
+ )
|
|
|
+ if crosses_review or crosses_version:
|
|
|
+ raise CandidateStateError(
|
|
|
+ "Cannot rewind across a committed candidate adoption"
|
|
|
+ )
|
|
|
+
|
|
|
async def _require_owner_trace(self, trace_id: str):
|
|
|
+ trace, root = await self._require_bound_trace(trace_id)
|
|
|
+ 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 _require_bound_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}")
|
|
|
@@ -233,11 +605,6 @@ class CandidateService:
|
|
|
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:
|