|
|
@@ -6,9 +6,10 @@ import asyncio
|
|
|
import hashlib
|
|
|
import json
|
|
|
import logging
|
|
|
+from datetime import datetime, timedelta, timezone
|
|
|
from dataclasses import replace
|
|
|
from dataclasses import asdict, is_dataclass
|
|
|
-from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
|
+from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
|
from uuid import uuid4
|
|
|
|
|
|
from agent.failures import FailureDetail, ToolExecutionError
|
|
|
@@ -24,12 +25,15 @@ from .models import (
|
|
|
CommandRecord,
|
|
|
CriterionResult,
|
|
|
DecisionAction,
|
|
|
+ DurableLeaseRecord,
|
|
|
EventDraft,
|
|
|
ExecutionStats,
|
|
|
FailureCode,
|
|
|
+ FrozenContextDocument,
|
|
|
OperationKind,
|
|
|
OperationStatus,
|
|
|
PlannerDecision,
|
|
|
+ ResourceClaim,
|
|
|
TaskAttempt,
|
|
|
TaskCycleResult,
|
|
|
TaskLedger,
|
|
|
@@ -37,6 +41,7 @@ from .models import (
|
|
|
TaskSpec,
|
|
|
TaskStatus,
|
|
|
ValidationReport,
|
|
|
+ ValidationEvidenceGrant,
|
|
|
ValidationMode,
|
|
|
ValidationPlan,
|
|
|
ValidationRunStatus,
|
|
|
@@ -73,12 +78,23 @@ from .validation_policy import (
|
|
|
ValidationContext,
|
|
|
ValidationPolicy,
|
|
|
)
|
|
|
+from .validation_evidence import (
|
|
|
+ cancel_read,
|
|
|
+ complete_read,
|
|
|
+ evidence_handle,
|
|
|
+ extend_snapshot,
|
|
|
+ require_authorized_refs,
|
|
|
+ require_complete,
|
|
|
+ reserve_read,
|
|
|
+ start_read_session,
|
|
|
+)
|
|
|
from ._decision_engine import DecisionEngine
|
|
|
from ._task_context import render_task_context
|
|
|
from .context_provider import (
|
|
|
RoleContextProvider,
|
|
|
RoleContextRequest,
|
|
|
TaskContextProvider,
|
|
|
+ ValidationContextBootstrap,
|
|
|
)
|
|
|
from .deterministic_worker import DeterministicWorker, DeterministicWorkerContext
|
|
|
from ._task_graph import (
|
|
|
@@ -256,6 +272,7 @@ class TaskCoordinator:
|
|
|
)
|
|
|
if task.status not in TERMINAL_TASK_STATUSES
|
|
|
]
|
|
|
+ blocking_failures = self._blocking_failures(ledger)
|
|
|
return {
|
|
|
"root_task_id": root.task_id,
|
|
|
"root_objective": ledger.root_objective,
|
|
|
@@ -263,7 +280,68 @@ class TaskCoordinator:
|
|
|
"blocked_reason": root.blocked_reason,
|
|
|
"result_summary": result_summary,
|
|
|
"pending_tasks": pending,
|
|
|
+ "blocking_failures": blocking_failures,
|
|
|
+ }
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _blocking_failures(ledger: TaskLedger) -> List[Dict[str, Any]]:
|
|
|
+ """Expose only the latest unresolved runtime failure per active Task."""
|
|
|
+
|
|
|
+ blocking: List[Dict[str, Any]] = []
|
|
|
+ resolved_task_statuses = {
|
|
|
+ TaskStatus.COMPLETED,
|
|
|
+ TaskStatus.CANCELLED,
|
|
|
+ TaskStatus.SUPERSEDED,
|
|
|
}
|
|
|
+ for task in ledger.tasks.values():
|
|
|
+ if task.status in resolved_task_statuses:
|
|
|
+ continue
|
|
|
+ attempts = [
|
|
|
+ ledger.attempts[item]
|
|
|
+ for item in task.attempt_ids
|
|
|
+ if item in ledger.attempts
|
|
|
+ ]
|
|
|
+ validations = [
|
|
|
+ ledger.validations[item]
|
|
|
+ for item in task.validation_ids
|
|
|
+ if item in ledger.validations
|
|
|
+ ]
|
|
|
+ latest_validation = next(
|
|
|
+ (item for item in reversed(validations) if item.failure is not None),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if latest_validation is not None and not any(
|
|
|
+ item.created_at > latest_validation.created_at
|
|
|
+ and item.status is ValidationRunStatus.COMPLETED
|
|
|
+ for item in validations
|
|
|
+ ):
|
|
|
+ blocking.append(
|
|
|
+ {
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "attempt_id": latest_validation.attempt_id,
|
|
|
+ "validation_id": latest_validation.validation_id,
|
|
|
+ **latest_validation.failure.to_dict(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ continue
|
|
|
+ latest_attempt = next(
|
|
|
+ (item for item in reversed(attempts) if item.failure is not None),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if latest_attempt is not None and not any(
|
|
|
+ item.created_at > latest_attempt.created_at
|
|
|
+ and item.status is AttemptStatus.SUBMITTED
|
|
|
+ for item in attempts
|
|
|
+ ):
|
|
|
+ blocking.append(
|
|
|
+ {
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "attempt_id": latest_attempt.attempt_id,
|
|
|
+ "validation_id": None,
|
|
|
+ **latest_attempt.failure.to_dict(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return blocking
|
|
|
|
|
|
async def mission_completion(self, root_trace_id: str) -> Dict[str, Any]:
|
|
|
"""Compatibility alias for callers using the pre-Root terminology."""
|
|
|
@@ -502,12 +580,21 @@ class TaskCoordinator:
|
|
|
parent_task_id: Optional[str] = None,
|
|
|
placement: Optional[Dict[str, Any]] = None,
|
|
|
idempotency_key: Optional[str] = None,
|
|
|
+ context_documents: Optional[Sequence["FrozenContextDocument"]] = None,
|
|
|
) -> Dict[str, Any]:
|
|
|
if not drafts:
|
|
|
raise ValueError("At least one task draft is required")
|
|
|
placement = dict(placement or {})
|
|
|
+ documents = tuple(context_documents or ())
|
|
|
|
|
|
def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ for document in documents:
|
|
|
+ existing = ledger.context_documents.get(document.uri)
|
|
|
+ if existing is not None and existing != document:
|
|
|
+ raise TaskConflict(
|
|
|
+ f"frozen context document conflict: {document.uri}"
|
|
|
+ )
|
|
|
+ ledger.context_documents[document.uri] = document
|
|
|
after_task_id = placement.get("after_task_id")
|
|
|
if after_task_id:
|
|
|
target = _task(ledger, after_task_id)
|
|
|
@@ -603,10 +690,279 @@ class TaskCoordinator:
|
|
|
"drafts": list(drafts),
|
|
|
"parent_task_id": parent_task_id,
|
|
|
"placement": placement,
|
|
|
+ "context_documents": [_plain(asdict(item)) for item in documents],
|
|
|
},
|
|
|
)
|
|
|
return await self._tasks_result(root_trace_id, result["task_ids"])
|
|
|
|
|
|
+ async def get_context_document(
|
|
|
+ self, root_trace_id: str, uri: str
|
|
|
+ ) -> "FrozenContextDocument":
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ try:
|
|
|
+ return ledger.context_documents[uri]
|
|
|
+ except KeyError as exc:
|
|
|
+ raise ValueError(f"Context document not found: {uri}") from exc
|
|
|
+
|
|
|
+ async def replay_command(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ *,
|
|
|
+ operation: str,
|
|
|
+ idempotency_key: str,
|
|
|
+ payload: Any,
|
|
|
+ ) -> Dict[str, Any] | None:
|
|
|
+ """Read a durable command result before rebuilding mutable snapshots."""
|
|
|
+
|
|
|
+ ledger = await self.task_store.load(root_trace_id)
|
|
|
+ return _command_replay(
|
|
|
+ ledger,
|
|
|
+ self._idem(root_trace_id, idempotency_key),
|
|
|
+ operation,
|
|
|
+ payload,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def get_tasks(
|
|
|
+ self, root_trace_id: str, task_ids: Sequence[str]
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ return await self._tasks_result(root_trace_id, task_ids)
|
|
|
+
|
|
|
+ async def reserve_durable_lease(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ lease_key: str,
|
|
|
+ *,
|
|
|
+ owner: str,
|
|
|
+ lease_seconds: int,
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ existing = ledger.durable_leases.get(lease_key)
|
|
|
+ if existing is not None and existing.status == "completed":
|
|
|
+ return {
|
|
|
+ "lease_key": lease_key,
|
|
|
+ "owner": existing.owner,
|
|
|
+ "epoch": existing.epoch,
|
|
|
+ "acquired": False,
|
|
|
+ "terminal_result": existing.terminal_result,
|
|
|
+ "journal": list(existing.journal),
|
|
|
+ }
|
|
|
+ expired = (
|
|
|
+ existing is None
|
|
|
+ or datetime.fromisoformat(existing.expires_at) <= now
|
|
|
+ )
|
|
|
+ if existing is not None and not expired:
|
|
|
+ return {
|
|
|
+ "lease_key": lease_key,
|
|
|
+ "owner": existing.owner,
|
|
|
+ "epoch": existing.epoch,
|
|
|
+ "acquired": False,
|
|
|
+ "terminal_result": None,
|
|
|
+ "journal": list(existing.journal),
|
|
|
+ }
|
|
|
+ epoch = 1 if existing is None else existing.epoch + int(expired)
|
|
|
+ record = DurableLeaseRecord(
|
|
|
+ lease_key=lease_key,
|
|
|
+ owner=owner,
|
|
|
+ epoch=epoch,
|
|
|
+ status="reserved",
|
|
|
+ expires_at=(now + timedelta(seconds=lease_seconds)).isoformat(),
|
|
|
+ journal=[] if existing is None else list(existing.journal),
|
|
|
+ )
|
|
|
+ ledger.durable_leases[lease_key] = record
|
|
|
+ return {
|
|
|
+ "lease_key": lease_key,
|
|
|
+ "owner": owner,
|
|
|
+ "epoch": epoch,
|
|
|
+ "acquired": True,
|
|
|
+ "terminal_result": None,
|
|
|
+ "journal": list(record.journal),
|
|
|
+ }
|
|
|
+
|
|
|
+ return await self._mutate(
|
|
|
+ root_trace_id,
|
|
|
+ "durable_lease_reserved",
|
|
|
+ mutate,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def append_durable_lease_event(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ lease_key: str,
|
|
|
+ *,
|
|
|
+ owner: str,
|
|
|
+ epoch: int,
|
|
|
+ event: Mapping[str, Any],
|
|
|
+ ) -> None:
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ lease = ledger.durable_leases[lease_key]
|
|
|
+ if (
|
|
|
+ lease.owner != owner
|
|
|
+ or lease.epoch != epoch
|
|
|
+ or lease.status != "reserved"
|
|
|
+ or datetime.fromisoformat(lease.expires_at) <= now
|
|
|
+ ):
|
|
|
+ raise TaskConflict("durable lease ownership is stale")
|
|
|
+ lease.journal.append(dict(event))
|
|
|
+ return {"lease_key": lease_key, "journal_size": len(lease.journal)}
|
|
|
+
|
|
|
+ await self._mutate(root_trace_id, "durable_lease_event_appended", mutate)
|
|
|
+
|
|
|
+ async def renew_durable_lease(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ lease_key: str,
|
|
|
+ *,
|
|
|
+ owner: str,
|
|
|
+ epoch: int,
|
|
|
+ lease_seconds: int,
|
|
|
+ ) -> None:
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ lease = ledger.durable_leases[lease_key]
|
|
|
+ if (
|
|
|
+ lease.owner != owner
|
|
|
+ or lease.epoch != epoch
|
|
|
+ or lease.status != "reserved"
|
|
|
+ or datetime.fromisoformat(lease.expires_at) <= now
|
|
|
+ ):
|
|
|
+ raise TaskConflict("durable lease ownership is stale")
|
|
|
+ lease.expires_at = (
|
|
|
+ now + timedelta(seconds=lease_seconds)
|
|
|
+ ).isoformat()
|
|
|
+ return {"lease_key": lease_key, "expires_at": lease.expires_at}
|
|
|
+
|
|
|
+ await self._mutate(root_trace_id, "durable_lease_renewed", mutate)
|
|
|
+
|
|
|
+ async def complete_durable_lease(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ lease_key: str,
|
|
|
+ *,
|
|
|
+ owner: str,
|
|
|
+ epoch: int,
|
|
|
+ result: Mapping[str, Any],
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ lease = ledger.durable_leases[lease_key]
|
|
|
+ if lease.status == "completed":
|
|
|
+ return dict(lease.terminal_result or {})
|
|
|
+ if (
|
|
|
+ lease.owner != owner
|
|
|
+ or lease.epoch != epoch
|
|
|
+ or lease.status != "reserved"
|
|
|
+ or datetime.fromisoformat(lease.expires_at) <= now
|
|
|
+ ):
|
|
|
+ raise TaskConflict("durable lease ownership is stale")
|
|
|
+ lease.status = "completed"
|
|
|
+ lease.terminal_result = dict(result)
|
|
|
+ return dict(result)
|
|
|
+
|
|
|
+ return await self._mutate(root_trace_id, "durable_lease_completed", mutate)
|
|
|
+
|
|
|
+ async def create_task_graph(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ drafts: Sequence[Dict[str, Any]],
|
|
|
+ *,
|
|
|
+ idempotency_key: str,
|
|
|
+ context_documents: Sequence["FrozenContextDocument"] = (),
|
|
|
+ command_payload: Any | None = None,
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ """Atomically freeze documents and create or revise a typed task plan."""
|
|
|
+
|
|
|
+ if not drafts:
|
|
|
+ raise ValueError("At least one task draft is required")
|
|
|
+ documents = tuple(context_documents)
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ for document in documents:
|
|
|
+ existing = ledger.context_documents.get(document.uri)
|
|
|
+ if existing is not None and existing != document:
|
|
|
+ raise TaskConflict(
|
|
|
+ f"frozen context document conflict: {document.uri}"
|
|
|
+ )
|
|
|
+ ledger.context_documents[document.uri] = document
|
|
|
+ created: List[str] = []
|
|
|
+ for draft in drafts:
|
|
|
+ revision_task_id = draft.get("_revision_task_id")
|
|
|
+ if revision_task_id is not None:
|
|
|
+ task_id = str(revision_task_id)
|
|
|
+ task = ledger.tasks.get(task_id)
|
|
|
+ if task is None:
|
|
|
+ raise ValueError(f"Revision task not found: {task_id}")
|
|
|
+ if task.status is not TaskStatus.NEEDS_REPLAN:
|
|
|
+ raise TaskConflict(
|
|
|
+ f"Task {task_id} cannot be revised from "
|
|
|
+ f"{task.status.value}"
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ draft.get("_parent_task_id") is not None
|
|
|
+ and str(draft["_parent_task_id"]) != task.parent_task_id
|
|
|
+ ):
|
|
|
+ raise TaskConflict(
|
|
|
+ f"Task {task_id} cannot change its frozen parent"
|
|
|
+ )
|
|
|
+ version = task.current_spec_version + 1
|
|
|
+ task.specs.append(
|
|
|
+ self._task_spec_from_draft(draft, version=version)
|
|
|
+ )
|
|
|
+ task.current_spec_version = version
|
|
|
+ transition(task.status, TaskStatus.PENDING)
|
|
|
+ task.status = TaskStatus.PENDING
|
|
|
+ task.updated_at = utc_now()
|
|
|
+ created.append(task_id)
|
|
|
+ continue
|
|
|
+ raw_parent = draft.get("_parent_task_id") or ledger.root_task_id
|
|
|
+ parent_id = str(raw_parent)
|
|
|
+ parent = ledger.tasks.get(parent_id)
|
|
|
+ if parent is None:
|
|
|
+ raise ValueError(f"Parent task not found: {parent_id}")
|
|
|
+ if parent.status not in {
|
|
|
+ TaskStatus.PENDING,
|
|
|
+ TaskStatus.NEEDS_REPLAN,
|
|
|
+ TaskStatus.WAITING_CHILDREN,
|
|
|
+ }:
|
|
|
+ raise TaskConflict(
|
|
|
+ f"Task {parent_id} cannot receive children from "
|
|
|
+ f"{parent.status.value}"
|
|
|
+ )
|
|
|
+ sibling_count = sum(
|
|
|
+ item.parent_task_id == parent_id for item in ledger.tasks.values()
|
|
|
+ )
|
|
|
+ task_id = self._add_task_record(
|
|
|
+ ledger,
|
|
|
+ {key: value for key, value in draft.items() if key != "_parent_task_id"},
|
|
|
+ parent_id,
|
|
|
+ self._display_path(ledger, parent_id, sibling_count + 1),
|
|
|
+ )
|
|
|
+ parent.child_task_ids.append(task_id)
|
|
|
+ self._mark_parent_waiting(parent)
|
|
|
+ created.append(task_id)
|
|
|
+ return {"task_ids": created}
|
|
|
+
|
|
|
+ result = await self._mutate(
|
|
|
+ root_trace_id,
|
|
|
+ "task_graph_created",
|
|
|
+ mutate,
|
|
|
+ idempotency_key,
|
|
|
+ idempotency_payload=(
|
|
|
+ command_payload
|
|
|
+ if command_payload is not None
|
|
|
+ else {
|
|
|
+ "drafts": list(drafts),
|
|
|
+ "context_documents": [_plain(asdict(item)) for item in documents],
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return await self._tasks_result(root_trace_id, result["task_ids"])
|
|
|
+
|
|
|
async def focus_task(self, root_trace_id: str, task_id: str) -> Dict[str, Any]:
|
|
|
def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
task = _task(ledger, task_id)
|
|
|
@@ -621,6 +977,7 @@ class TaskCoordinator:
|
|
|
task_ids: Sequence[str],
|
|
|
worker_presets: Optional[Sequence[str]] = None,
|
|
|
idempotency_key: Optional[str] = None,
|
|
|
+ resource_claims: Optional[Sequence["ResourceClaim"]] = None,
|
|
|
) -> List[TaskCycleResult]:
|
|
|
if not task_ids:
|
|
|
return []
|
|
|
@@ -630,6 +987,7 @@ class TaskCoordinator:
|
|
|
task_ids=task_ids,
|
|
|
worker_presets=worker_presets,
|
|
|
idempotency_key=idempotency_key,
|
|
|
+ resource_claims=resource_claims,
|
|
|
)
|
|
|
operation = await self.await_operation(root_trace_id, operation.operation_id)
|
|
|
return await self._operation_cycle_results(operation)
|
|
|
@@ -645,6 +1003,7 @@ class TaskCoordinator:
|
|
|
attempt_id: Optional[str] = None,
|
|
|
idempotency_key: Optional[str] = None,
|
|
|
deadline_at: Optional[str] = None,
|
|
|
+ resource_claims: Optional[Sequence["ResourceClaim"]] = None,
|
|
|
) -> BackgroundOperation:
|
|
|
return await self._operations.start(
|
|
|
root_trace_id,
|
|
|
@@ -655,6 +1014,7 @@ class TaskCoordinator:
|
|
|
attempt_id=attempt_id,
|
|
|
idempotency_key=idempotency_key,
|
|
|
deadline_at=deadline_at,
|
|
|
+ resource_claims=resource_claims,
|
|
|
)
|
|
|
|
|
|
async def get_operation(
|
|
|
@@ -1666,6 +2026,7 @@ class TaskCoordinator:
|
|
|
attempt=attempt,
|
|
|
snapshot=snapshot,
|
|
|
prior_validation_count=max(len(task.validation_ids) - 1, 0),
|
|
|
+ context_documents=ledger.context_documents,
|
|
|
)
|
|
|
preflight_result = await self._run_validation_preflight(
|
|
|
validation_context,
|
|
|
@@ -1689,7 +2050,7 @@ class TaskCoordinator:
|
|
|
timeout=timeout,
|
|
|
)
|
|
|
else:
|
|
|
- validator_context["role_context"] = await self._build_role_context(
|
|
|
+ bootstrap = await self._build_validation_context(
|
|
|
RoleContextRequest(
|
|
|
role=AgentRole.VALIDATOR,
|
|
|
root_trace_id=root_trace_id,
|
|
|
@@ -1707,6 +2068,13 @@ class TaskCoordinator:
|
|
|
},
|
|
|
)
|
|
|
)
|
|
|
+ await self.initialize_validation_evidence(
|
|
|
+ root_trace_id,
|
|
|
+ validation_id,
|
|
|
+ grants=bootstrap.evidence_grants,
|
|
|
+ required_handles=bootstrap.required_handles,
|
|
|
+ )
|
|
|
+ validator_context["role_context"] = _plain(bootstrap.role_context)
|
|
|
validator_result = await asyncio.wait_for(
|
|
|
self.executor.run_validator(validator_context), # type: ignore[union-attr]
|
|
|
timeout=timeout,
|
|
|
@@ -1792,6 +2160,24 @@ class TaskCoordinator:
|
|
|
value = dict(value)
|
|
|
return _plain(value)
|
|
|
|
|
|
+ async def _build_validation_context(
|
|
|
+ self, request: RoleContextRequest
|
|
|
+ ) -> ValidationContextBootstrap:
|
|
|
+ if self.role_context_provider is None:
|
|
|
+ return ValidationContextBootstrap({}, ())
|
|
|
+ builder = getattr(self.role_context_provider, "build_validation_context", None)
|
|
|
+ if callable(builder):
|
|
|
+ value = await builder(request)
|
|
|
+ if not isinstance(value, ValidationContextBootstrap):
|
|
|
+ raise TypeError(
|
|
|
+ "build_validation_context must return ValidationContextBootstrap"
|
|
|
+ )
|
|
|
+ return value
|
|
|
+ return ValidationContextBootstrap(
|
|
|
+ await self._build_role_context(request),
|
|
|
+ (),
|
|
|
+ )
|
|
|
+
|
|
|
async def _reuse_semantic_validation(
|
|
|
self,
|
|
|
context: ValidationContext,
|
|
|
@@ -1826,6 +2212,19 @@ class TaskCoordinator:
|
|
|
or prior_snapshot.evidence_refs != context.snapshot.evidence_refs
|
|
|
):
|
|
|
continue
|
|
|
+ await self.initialize_validation_evidence(
|
|
|
+ context.root_trace_id,
|
|
|
+ validation.validation_id,
|
|
|
+ grants=tuple(
|
|
|
+ ValidationEvidenceGrant(
|
|
|
+ artifact_ref=ref,
|
|
|
+ handle=evidence_handle(ref),
|
|
|
+ source="reused_validation",
|
|
|
+ )
|
|
|
+ for ref in prior.evidence_refs
|
|
|
+ ),
|
|
|
+ required_handles=(),
|
|
|
+ )
|
|
|
await self.submit_validation(
|
|
|
{
|
|
|
"role": AgentRole.VALIDATOR.value,
|
|
|
@@ -1886,6 +2285,12 @@ class TaskCoordinator:
|
|
|
if item.verdict == ValidationVerdict.FAILED
|
|
|
]
|
|
|
reason = "; ".join(f"{item.rule_id}: {item.reason}" for item in failures)
|
|
|
+ await self.initialize_validation_evidence(
|
|
|
+ context.root_trace_id,
|
|
|
+ validation.validation_id,
|
|
|
+ grants=(),
|
|
|
+ required_handles=(),
|
|
|
+ )
|
|
|
await self.submit_validation(
|
|
|
{
|
|
|
"role": AgentRole.VALIDATOR.value,
|
|
|
@@ -1965,6 +2370,19 @@ class TaskCoordinator:
|
|
|
evidence_refs = [
|
|
|
ref for item in result.rule_results for ref in item.evidence_refs
|
|
|
]
|
|
|
+ await self.initialize_validation_evidence(
|
|
|
+ context.root_trace_id,
|
|
|
+ validation.validation_id,
|
|
|
+ grants=tuple(
|
|
|
+ ValidationEvidenceGrant(
|
|
|
+ artifact_ref=ref,
|
|
|
+ handle=evidence_handle(ref),
|
|
|
+ source="deterministic_validation",
|
|
|
+ )
|
|
|
+ for ref in evidence_refs
|
|
|
+ ),
|
|
|
+ required_handles=(),
|
|
|
+ )
|
|
|
errors = result.errors
|
|
|
summary = (
|
|
|
f"Deterministic validation {result.verdict.value}; "
|
|
|
@@ -2228,6 +2646,7 @@ class TaskCoordinator:
|
|
|
ledger.validations[item].attempt_id == attempt_id
|
|
|
for item in task.validation_ids
|
|
|
),
|
|
|
+ context_documents=ledger.context_documents,
|
|
|
)
|
|
|
plan = self.validation_policy.plan(context)
|
|
|
if not isinstance(plan, ValidationPlan):
|
|
|
@@ -2316,14 +2735,39 @@ class TaskCoordinator:
|
|
|
or task.status != TaskStatus.VALIDATING
|
|
|
):
|
|
|
raise TaskConflict("Validation can only be submitted once while running")
|
|
|
- self._validate_report(task, verdict, criterion_results)
|
|
|
-
|
|
|
def mutate(current: TaskLedger) -> Dict[str, Any]:
|
|
|
current_task = _task(current, task_id)
|
|
|
report = current.validations[validation_id]
|
|
|
+ if (
|
|
|
+ report.task_id != task_id
|
|
|
+ or report.attempt_id != attempt_id
|
|
|
+ or report.snapshot_id != snapshot_id
|
|
|
+ or report.validator_trace_id != actor_context.get("trace_id")
|
|
|
+ ):
|
|
|
+ raise OrchestrationError(
|
|
|
+ "Validation identity changed before submission"
|
|
|
+ )
|
|
|
self._assert_execution_owner(current, report, actor_context)
|
|
|
- if report.status != ValidationRunStatus.RUNNING:
|
|
|
+ if (
|
|
|
+ report.status != ValidationRunStatus.RUNNING
|
|
|
+ or current_task.status != TaskStatus.VALIDATING
|
|
|
+ ):
|
|
|
raise TaskConflict("Validation was already submitted")
|
|
|
+ if report.read_session is None:
|
|
|
+ raise TaskConflict("Validation evidence was not initialized")
|
|
|
+ require_complete(report.read_session)
|
|
|
+ require_authorized_refs(
|
|
|
+ report.evidence_snapshot,
|
|
|
+ (
|
|
|
+ *evidence_refs,
|
|
|
+ *(
|
|
|
+ ref
|
|
|
+ for result in criterion_results
|
|
|
+ for ref in result.evidence_refs
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ self._validate_report(current_task, verdict, criterion_results)
|
|
|
report.status = ValidationRunStatus.COMPLETED
|
|
|
report.verdict = verdict
|
|
|
report.criterion_results = list(criterion_results)
|
|
|
@@ -2354,6 +2798,197 @@ class TaskCoordinator:
|
|
|
)
|
|
|
return result
|
|
|
|
|
|
+ async def grant_validation_evidence(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ validation_id: str,
|
|
|
+ grants: Sequence[ValidationEvidenceGrant],
|
|
|
+ *,
|
|
|
+ require_read: bool = False,
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ if (
|
|
|
+ validation.read_session is not None
|
|
|
+ and validation.read_session.active_reads
|
|
|
+ ):
|
|
|
+ raise TaskConflict(
|
|
|
+ "Validation evidence cannot change during an active page read"
|
|
|
+ )
|
|
|
+ validation.evidence_snapshot = extend_snapshot(
|
|
|
+ validation.evidence_snapshot, grants
|
|
|
+ )
|
|
|
+ if validation.read_session is not None:
|
|
|
+ required = validation.read_session.required_handles
|
|
|
+ if require_read:
|
|
|
+ required = tuple(
|
|
|
+ dict.fromkeys((*required, *(item.handle for item in grants)))
|
|
|
+ )
|
|
|
+ validation.read_session = replace(
|
|
|
+ validation.read_session,
|
|
|
+ evidence_revision=validation.evidence_snapshot.revision,
|
|
|
+ required_handles=required,
|
|
|
+ authorized_handles=tuple(
|
|
|
+ item.handle for item in validation.evidence_snapshot.grants
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "evidence_revision": validation.evidence_snapshot.revision,
|
|
|
+ }
|
|
|
+
|
|
|
+ return await self._mutate(
|
|
|
+ root_trace_id, "validation_evidence_granted", mutate
|
|
|
+ )
|
|
|
+
|
|
|
+ async def initialize_validation_evidence(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ validation_id: str,
|
|
|
+ *,
|
|
|
+ grants: Sequence[ValidationEvidenceGrant],
|
|
|
+ required_handles: Sequence[str],
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ """Freeze initial grants and read requirements in one Ledger mutation."""
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ if validation.read_session is not None:
|
|
|
+ raise TaskConflict("Validation evidence was already initialized")
|
|
|
+ validation.evidence_snapshot = extend_snapshot(
|
|
|
+ validation.evidence_snapshot, grants
|
|
|
+ )
|
|
|
+ validation.read_session = start_read_session(
|
|
|
+ validation.evidence_snapshot, required_handles
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "evidence_revision": validation.evidence_snapshot.revision,
|
|
|
+ "required_handles": list(validation.read_session.required_handles),
|
|
|
+ }
|
|
|
+
|
|
|
+ return await self._mutate(
|
|
|
+ root_trace_id, "validation_evidence_initialized", mutate
|
|
|
+ )
|
|
|
+
|
|
|
+ async def apply_packed_role_context(
|
|
|
+ self,
|
|
|
+ role: AgentRole,
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ required_handles: Sequence[str],
|
|
|
+ ) -> None:
|
|
|
+ """Persist final-packer read requirements before a Validator call."""
|
|
|
+
|
|
|
+ if role is not AgentRole.VALIDATOR:
|
|
|
+ return
|
|
|
+ root_trace_id = str(context.get("root_trace_id") or "")
|
|
|
+ validation_id = str(context.get("validation_id") or "")
|
|
|
+ if not root_trace_id or not validation_id:
|
|
|
+ raise TaskConflict("packed Validator context has no Validation identity")
|
|
|
+
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ session = validation.read_session
|
|
|
+ if session is None:
|
|
|
+ raise TaskConflict("Validation has no evidence read session")
|
|
|
+ if set(required_handles) - set(session.authorized_handles):
|
|
|
+ raise TaskConflict("packed context requires unauthorized evidence")
|
|
|
+ validation.read_session = replace(
|
|
|
+ session,
|
|
|
+ required_handles=tuple(
|
|
|
+ dict.fromkeys((*session.required_handles, *required_handles))
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "required_handles": list(validation.read_session.required_handles),
|
|
|
+ }
|
|
|
+
|
|
|
+ await self._mutate(root_trace_id, "validation_prompt_packed", mutate)
|
|
|
+
|
|
|
+ async def reserve_validation_read(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ validation_id: str,
|
|
|
+ *,
|
|
|
+ evidence_revision: int,
|
|
|
+ handle: str,
|
|
|
+ cursor: str | None,
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ if validation.read_session is None:
|
|
|
+ raise TaskConflict("Validation has no evidence read session")
|
|
|
+ session, replay_result = reserve_read(
|
|
|
+ validation.read_session,
|
|
|
+ evidence_revision=evidence_revision,
|
|
|
+ handle=handle,
|
|
|
+ cursor=cursor,
|
|
|
+ )
|
|
|
+ validation.read_session = session
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "replayed": replay_result is not None,
|
|
|
+ "result": dict(replay_result) if replay_result is not None else None,
|
|
|
+ }
|
|
|
+
|
|
|
+ return await self._mutate(root_trace_id, "validation_read_reserved", mutate)
|
|
|
+
|
|
|
+ async def complete_validation_read(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ validation_id: str,
|
|
|
+ *,
|
|
|
+ evidence_revision: int,
|
|
|
+ handle: str,
|
|
|
+ cursor: str | None,
|
|
|
+ next_cursor: str | None,
|
|
|
+ exhausted: bool,
|
|
|
+ result: Mapping[str, Any],
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ if validation.read_session is None:
|
|
|
+ raise TaskConflict("Validation has no evidence read session")
|
|
|
+ validation.read_session = complete_read(
|
|
|
+ validation.read_session,
|
|
|
+ evidence_revision=evidence_revision,
|
|
|
+ handle=handle,
|
|
|
+ cursor=cursor,
|
|
|
+ next_cursor=next_cursor,
|
|
|
+ exhausted=exhausted,
|
|
|
+ result=result,
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "completed": handle in validation.read_session.completed_handles,
|
|
|
+ }
|
|
|
+
|
|
|
+ return await self._mutate(root_trace_id, "validation_read_completed", mutate)
|
|
|
+
|
|
|
+ async def cancel_validation_read(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ validation_id: str,
|
|
|
+ *,
|
|
|
+ evidence_revision: int,
|
|
|
+ handle: str,
|
|
|
+ cursor: str | None,
|
|
|
+ ) -> Dict[str, Any]:
|
|
|
+ def mutate(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ if validation.read_session is None:
|
|
|
+ raise TaskConflict("Validation has no evidence read session")
|
|
|
+ validation.read_session = cancel_read(
|
|
|
+ validation.read_session,
|
|
|
+ evidence_revision=evidence_revision,
|
|
|
+ handle=handle,
|
|
|
+ cursor=cursor,
|
|
|
+ )
|
|
|
+ return {"validation_id": validation_id, "cancelled": True}
|
|
|
+
|
|
|
+ return await self._mutate(root_trace_id, "validation_read_cancelled", mutate)
|
|
|
+
|
|
|
async def query_evidence(
|
|
|
self,
|
|
|
actor_context: Dict[str, Any],
|
|
|
@@ -2596,7 +3231,8 @@ class TaskCoordinator:
|
|
|
response: EvidenceResponse,
|
|
|
) -> None:
|
|
|
def complete(ledger: TaskLedger) -> Dict[str, Any]:
|
|
|
- record = ledger.validations[validation_id].evidence_query_results[query_key]
|
|
|
+ validation = ledger.validations[validation_id]
|
|
|
+ record = validation.evidence_query_results[query_key]
|
|
|
record.update(
|
|
|
{
|
|
|
"status": "completed",
|
|
|
@@ -2606,7 +3242,39 @@ class TaskCoordinator:
|
|
|
"queries_remaining": response.queries_remaining,
|
|
|
}
|
|
|
)
|
|
|
- return {"validation_id": validation_id, "status": "completed"}
|
|
|
+ grants = tuple(
|
|
|
+ ValidationEvidenceGrant(
|
|
|
+ artifact_ref=ref,
|
|
|
+ handle=evidence_handle(ref),
|
|
|
+ source="validation_evidence_query",
|
|
|
+ )
|
|
|
+ for ref in response.evidence_refs
|
|
|
+ )
|
|
|
+ validation.evidence_snapshot = extend_snapshot(
|
|
|
+ validation.evidence_snapshot, grants
|
|
|
+ )
|
|
|
+ if validation.read_session is None:
|
|
|
+ raise TaskConflict("Validation has no evidence read session")
|
|
|
+ validation.read_session = replace(
|
|
|
+ validation.read_session,
|
|
|
+ evidence_revision=validation.evidence_snapshot.revision,
|
|
|
+ required_handles=tuple(
|
|
|
+ dict.fromkeys(
|
|
|
+ (
|
|
|
+ *validation.read_session.required_handles,
|
|
|
+ *(item.handle for item in grants),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ authorized_handles=tuple(
|
|
|
+ item.handle for item in validation.evidence_snapshot.grants
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ return {
|
|
|
+ "validation_id": validation_id,
|
|
|
+ "status": "completed",
|
|
|
+ "evidence_revision": validation.evidence_snapshot.revision,
|
|
|
+ }
|
|
|
|
|
|
await self._mutate(root_trace_id, "evidence_query_completed", complete)
|
|
|
|
|
|
@@ -2824,6 +3492,10 @@ class TaskCoordinator:
|
|
|
)
|
|
|
if task.status != TaskStatus.NEEDS_REPLAN:
|
|
|
raise TaskConflict("Task must be in needs_replan before revalidation")
|
|
|
+ if attempt.spec_version != task.current_spec_version:
|
|
|
+ raise TaskConflict(
|
|
|
+ "Revalidation cannot use an Attempt from a stale Task spec"
|
|
|
+ )
|
|
|
report = ValidationReport(
|
|
|
validation_id=new_id(),
|
|
|
task_id=task_id,
|