|
|
@@ -0,0 +1,583 @@
|
|
|
+"""Idempotent Phase2-to-Phase3 Root continuation."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import asyncio
|
|
|
+from collections.abc import AsyncIterator, Callable
|
|
|
+from dataclasses import dataclass
|
|
|
+from typing import Any, cast
|
|
|
+
|
|
|
+from agent.orchestration import DecisionAction, OperationStatus, TaskStatus, ValidationVerdict
|
|
|
+
|
|
|
+from script_build_host.application.input_snapshot_service import ScriptInputSnapshotService
|
|
|
+from script_build_host.application.mission_factory import ScriptMissionFactory
|
|
|
+from script_build_host.domain.artifacts import ArtifactState, ScriptDirectionArtifactV1
|
|
|
+from script_build_host.domain.canonical_json import canonical_sha256
|
|
|
+from script_build_host.domain.errors import (
|
|
|
+ MissionRecoveryRequired,
|
|
|
+ PhaseThreeBoundaryNotReady,
|
|
|
+ ProtocolViolation,
|
|
|
+)
|
|
|
+from script_build_host.domain.phase_two_artifacts import CandidatePortfolioArtifactV1
|
|
|
+from script_build_host.domain.ports import (
|
|
|
+ BuildAuthorizer,
|
|
|
+ LegacyBuildStateRepository,
|
|
|
+ MissionBindingRepository,
|
|
|
+ PromptRequest,
|
|
|
+ ScriptBusinessArtifactRepository,
|
|
|
+)
|
|
|
+from script_build_host.domain.records import BuildStatus, MissionOwnerToken, Principal
|
|
|
+from script_build_host.domain.task_contracts import (
|
|
|
+ AcceptedDecisionRef,
|
|
|
+ ScriptCriterion,
|
|
|
+ ScriptIntentClass,
|
|
|
+ ScriptTaskBudget,
|
|
|
+ ScriptTaskContractV1,
|
|
|
+ ScriptTaskKind,
|
|
|
+)
|
|
|
+from script_build_host.domain.task_refs import TASK_KIND_PREFIX, task_kind
|
|
|
+from script_build_host.infrastructure.ownership import OwnerLease
|
|
|
+
|
|
|
+PHASE_TWO_CANDIDATE_PORTFOLIO_READY = "PHASE_TWO_CANDIDATE_PORTFOLIO_READY"
|
|
|
+PHASE_THREE_ROOT_ACCEPTED = "PHASE_THREE_ROOT_ACCEPTED"
|
|
|
+PHASE_THREE_POLICY = "script-build-phase-three/v1"
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class PhaseThreeAdvanceResult:
|
|
|
+ script_build_id: int
|
|
|
+ status: BuildStatus
|
|
|
+ root_trace_id: str
|
|
|
+ input_snapshot_id: str
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
+class _AcceptedRootClosure:
|
|
|
+ direction: AcceptedDecisionRef
|
|
|
+ portfolio: AcceptedDecisionRef
|
|
|
+ structured_script_ref: str
|
|
|
+
|
|
|
+
|
|
|
+class PhaseThreeContinuationService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ runner: Any,
|
|
|
+ coordinator: Any,
|
|
|
+ factory: ScriptMissionFactory,
|
|
|
+ input_snapshots: ScriptInputSnapshotService,
|
|
|
+ bindings: MissionBindingRepository,
|
|
|
+ artifacts: ScriptBusinessArtifactRepository,
|
|
|
+ legacy_state: LegacyBuildStateRepository,
|
|
|
+ authorizer: BuildAuthorizer,
|
|
|
+ contracts: Any,
|
|
|
+ owner_lease_factory: Callable[[int], OwnerLease],
|
|
|
+ phase_two_boundary_verifier: Any,
|
|
|
+ prompt_requests: tuple[PromptRequest, ...],
|
|
|
+ model_manifest: dict[str, Any] | None = None,
|
|
|
+ fenced_command_gate: Any | None = None,
|
|
|
+ owner_registry: Any | None = None,
|
|
|
+ ) -> None:
|
|
|
+ self.runner = runner
|
|
|
+ self.coordinator = coordinator
|
|
|
+ self.factory = factory
|
|
|
+ self.input_snapshots = input_snapshots
|
|
|
+ self.bindings = bindings
|
|
|
+ self.artifacts = artifacts
|
|
|
+ self.legacy_state = legacy_state
|
|
|
+ self.authorizer = authorizer
|
|
|
+ self.contracts = contracts
|
|
|
+ self.owner_lease_factory = owner_lease_factory
|
|
|
+ self.phase_two_boundary_verifier = phase_two_boundary_verifier
|
|
|
+ self.prompt_requests = prompt_requests
|
|
|
+ self.model_manifest = dict(model_manifest or {})
|
|
|
+ self.fenced_command_gate = fenced_command_gate
|
|
|
+ self.owner_registry = owner_registry
|
|
|
+ self._locks: dict[int, asyncio.Lock] = {}
|
|
|
+ self._runs: dict[int, asyncio.Task[None]] = {}
|
|
|
+
|
|
|
+ async def advance(self, script_build_id: int, principal: Principal) -> PhaseThreeAdvanceResult:
|
|
|
+ await self.authorizer.require_access(principal, script_build_id)
|
|
|
+ lock = self._locks.setdefault(script_build_id, asyncio.Lock())
|
|
|
+ async with lock:
|
|
|
+ binding = await self.bindings.get_by_build(script_build_id)
|
|
|
+ active = self._runs.get(script_build_id)
|
|
|
+ if active is not None and not active.done():
|
|
|
+ return PhaseThreeAdvanceResult(
|
|
|
+ script_build_id,
|
|
|
+ BuildStatus.RUNNING,
|
|
|
+ binding.root_trace_id,
|
|
|
+ str(binding.input_snapshot_id),
|
|
|
+ )
|
|
|
+ completion = await self.coordinator.root_completion(binding.root_trace_id)
|
|
|
+ status = await self.legacy_state.get_status(script_build_id)
|
|
|
+ if completion.get("status") == TaskStatus.COMPLETED.value:
|
|
|
+ return PhaseThreeAdvanceResult(
|
|
|
+ script_build_id,
|
|
|
+ status,
|
|
|
+ binding.root_trace_id,
|
|
|
+ str(binding.input_snapshot_id),
|
|
|
+ )
|
|
|
+ if status is not BuildStatus.PARTIAL:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three advance requires a partial build")
|
|
|
+ if (
|
|
|
+ completion.get("status") != TaskStatus.BLOCKED.value
|
|
|
+ or completion.get("blocked_reason") != PHASE_TWO_CANDIDATE_PORTFOLIO_READY
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "phase-three advance requires the phase-two portfolio checkpoint"
|
|
|
+ )
|
|
|
+ if self.phase_two_boundary_verifier is None:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-two closure verifier is unavailable")
|
|
|
+ await self.phase_two_boundary_verifier.verify_checkpoint(
|
|
|
+ script_build_id=script_build_id,
|
|
|
+ root_trace_id=binding.root_trace_id,
|
|
|
+ input_snapshot_id=str(binding.input_snapshot_id),
|
|
|
+ )
|
|
|
+ ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
|
+ if any(
|
|
|
+ item.status
|
|
|
+ in {
|
|
|
+ OperationStatus.PENDING,
|
|
|
+ OperationStatus.RUNNING,
|
|
|
+ OperationStatus.STOP_REQUESTED,
|
|
|
+ }
|
|
|
+ for item in ledger.operations.values()
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase two still has active operations")
|
|
|
+ lease = self.owner_lease_factory(script_build_id)
|
|
|
+ token = await lease.acquire(script_build_id)
|
|
|
+ task = asyncio.create_task(
|
|
|
+ self._run_owned(script_build_id, token, lease),
|
|
|
+ name=f"script-build:{script_build_id}:phase-three",
|
|
|
+ )
|
|
|
+ self._runs[script_build_id] = task
|
|
|
+ task.add_done_callback(lambda done: self._discard(script_build_id, done))
|
|
|
+ return PhaseThreeAdvanceResult(
|
|
|
+ script_build_id,
|
|
|
+ BuildStatus.RUNNING,
|
|
|
+ binding.root_trace_id,
|
|
|
+ str(binding.input_snapshot_id),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _run_owned(
|
|
|
+ self, script_build_id: int, token: MissionOwnerToken, lease: OwnerLease
|
|
|
+ ) -> None:
|
|
|
+ if self.owner_registry is not None:
|
|
|
+ self.owner_registry.register_owner_token(token)
|
|
|
+ try:
|
|
|
+ await self.run(script_build_id, owner_token=token)
|
|
|
+ finally:
|
|
|
+ if self.owner_registry is not None:
|
|
|
+ self.owner_registry.unregister_owner_token(token)
|
|
|
+ await lease.release()
|
|
|
+
|
|
|
+ async def run(self, script_build_id: int, *, owner_token: MissionOwnerToken) -> None:
|
|
|
+ await self._verify_owner(owner_token)
|
|
|
+ binding = await self.bindings.get_by_build(script_build_id)
|
|
|
+ if binding.root_trace_id != owner_token.root_trace_id:
|
|
|
+ raise PhaseThreeBoundaryNotReady("owner token is bound to another Root")
|
|
|
+ status = await self.legacy_state.get_status(script_build_id)
|
|
|
+ if status is not BuildStatus.PARTIAL:
|
|
|
+ raise PhaseThreeBoundaryNotReady(f"cannot continue a {status.value} build")
|
|
|
+ snapshot = await self.input_snapshots.get(
|
|
|
+ str(binding.input_snapshot_id), script_build_id=script_build_id
|
|
|
+ )
|
|
|
+ ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ if root.status is TaskStatus.COMPLETED:
|
|
|
+ return
|
|
|
+ if root.status is TaskStatus.BLOCKED:
|
|
|
+ if root.blocked_reason != PHASE_TWO_CANDIDATE_PORTFOLIO_READY:
|
|
|
+ raise PhaseThreeBoundaryNotReady("Root is not at the phase-two checkpoint")
|
|
|
+ if self.phase_two_boundary_verifier is None:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-two closure verifier is unavailable")
|
|
|
+ await self.phase_two_boundary_verifier.verify_checkpoint(
|
|
|
+ script_build_id=script_build_id,
|
|
|
+ root_trace_id=binding.root_trace_id,
|
|
|
+ input_snapshot_id=str(binding.input_snapshot_id),
|
|
|
+ )
|
|
|
+ active_operations = [
|
|
|
+ item
|
|
|
+ for item in ledger.operations.values()
|
|
|
+ if item.status
|
|
|
+ in {OperationStatus.PENDING, OperationStatus.RUNNING, OperationStatus.STOP_REQUESTED}
|
|
|
+ ]
|
|
|
+ if active_operations:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase two still has active operations")
|
|
|
+ closure = await self._accepted_root_closure(script_build_id, ledger)
|
|
|
+ await self._verify_owner(owner_token)
|
|
|
+ snapshot, binding = await self._ensure_snapshot(snapshot, binding)
|
|
|
+ root_contract = await self._execute_fenced(
|
|
|
+ owner_token,
|
|
|
+ lambda: self._freeze_root_contract(
|
|
|
+ binding.root_trace_id, root, closure, str(snapshot.snapshot_id)
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ policy = self.factory.build_phase_three_policy(snapshot)
|
|
|
+ message = self.factory.build_phase_three_message(
|
|
|
+ binding,
|
|
|
+ snapshot,
|
|
|
+ direction_ref=closure.direction.artifact_ref.uri,
|
|
|
+ portfolio_ref=closure.portfolio.artifact_ref.uri,
|
|
|
+ structured_script_ref=closure.structured_script_ref,
|
|
|
+ )
|
|
|
+ iterator = await self._execute_fenced(
|
|
|
+ owner_token,
|
|
|
+ lambda: self._prepare_policy_messages(binding, snapshot, policy, message),
|
|
|
+ )
|
|
|
+ await self._verify_owner(owner_token)
|
|
|
+ ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ if root.status is TaskStatus.BLOCKED:
|
|
|
+ await self._execute_fenced(
|
|
|
+ owner_token,
|
|
|
+ lambda: self.coordinator.decide_task(
|
|
|
+ binding.root_trace_id,
|
|
|
+ root.task_id,
|
|
|
+ None,
|
|
|
+ DecisionAction.UNBLOCK,
|
|
|
+ {},
|
|
|
+ f"phase-three-unblock:{script_build_id}",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ ledger = await self.coordinator.task_store.load(binding.root_trace_id)
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ if task_kind(root.current_spec.context_refs) != ScriptTaskKind.ROOT_DELIVERY.value:
|
|
|
+ if root.status is not TaskStatus.NEEDS_REPLAN:
|
|
|
+ raise PhaseThreeBoundaryNotReady("Root cannot accept its delivery contract")
|
|
|
+ await self._execute_fenced(
|
|
|
+ owner_token,
|
|
|
+ lambda: self.coordinator.decide_task(
|
|
|
+ binding.root_trace_id,
|
|
|
+ root.task_id,
|
|
|
+ None,
|
|
|
+ DecisionAction.REVISE,
|
|
|
+ {
|
|
|
+ "reason": "enter the Host-frozen phase-three delivery contract",
|
|
|
+ "objective": root.current_spec.objective,
|
|
|
+ "acceptance_criteria": [
|
|
|
+ {
|
|
|
+ "criterion_id": item.criterion_id,
|
|
|
+ "description": item.description,
|
|
|
+ "hard": item.hard,
|
|
|
+ }
|
|
|
+ for item in root.current_spec.acceptance_criteria
|
|
|
+ ],
|
|
|
+ "context_refs": [
|
|
|
+ f"{TASK_KIND_PREFIX}{ScriptTaskKind.ROOT_DELIVERY.value}",
|
|
|
+ root_contract.uri,
|
|
|
+ f"script-build://inputs/{snapshot.snapshot_id}",
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ f"phase-three-contract:{script_build_id}",
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ await self.legacy_state.begin_phase(script_build_id)
|
|
|
+ await self._verify_owner(owner_token)
|
|
|
+ if iterator is None:
|
|
|
+ await self.runner.run_result(
|
|
|
+ messages=[], config=self.factory.build_phase_three_run_config(binding, snapshot)
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ async for _ in iterator:
|
|
|
+ pass
|
|
|
+ completion = await self.coordinator.root_completion(binding.root_trace_id)
|
|
|
+ await self._verify_owner(owner_token)
|
|
|
+ if completion.get("status") != TaskStatus.COMPLETED.value:
|
|
|
+ raise ProtocolViolation("phase-three Root was not explicitly ACCEPTed")
|
|
|
+ await self.legacy_state.set_checkpoint(
|
|
|
+ script_build_id,
|
|
|
+ checkpoint_code=PHASE_THREE_ROOT_ACCEPTED,
|
|
|
+ summary="Root accepted one validated immutable delivery manifest.",
|
|
|
+ root_trace_id=binding.root_trace_id,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _verify_owner(self, token: MissionOwnerToken) -> None:
|
|
|
+ if self.fenced_command_gate is not None:
|
|
|
+ await self.fenced_command_gate.verify(token)
|
|
|
+
|
|
|
+ async def _execute_fenced(self, token: MissionOwnerToken, mutation: Any) -> Any:
|
|
|
+ if self.owner_registry is not None:
|
|
|
+ return await self.owner_registry.execute_fenced(token.script_build_id, mutation)
|
|
|
+ await self._verify_owner(token)
|
|
|
+ return await mutation()
|
|
|
+
|
|
|
+ async def _ensure_snapshot(self, snapshot: Any, binding: Any) -> tuple[Any, Any]:
|
|
|
+ presets = {str(item.get("preset")) for item in snapshot.prompt_manifest}
|
|
|
+ required = {item.preset for item in self.prompt_requests}
|
|
|
+ if required <= presets:
|
|
|
+ return snapshot, binding
|
|
|
+ extended = await self.input_snapshots.extend_prompt_lineage(
|
|
|
+ snapshot,
|
|
|
+ prompt_requests=self.prompt_requests,
|
|
|
+ model_manifest=self.model_manifest or snapshot.model_manifest,
|
|
|
+ version=3,
|
|
|
+ )
|
|
|
+ binding = await self.bindings.compare_and_set_input_snapshot(
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ expected_snapshot_id=int(snapshot.snapshot_id),
|
|
|
+ new_snapshot_id=int(extended.snapshot_id),
|
|
|
+ )
|
|
|
+ return extended, binding
|
|
|
+
|
|
|
+ async def _accepted_root_closure(
|
|
|
+ self, script_build_id: int, ledger: Any
|
|
|
+ ) -> _AcceptedRootClosure:
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ if len(root.child_task_ids) != 2:
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "Root must have exactly the accepted Direction and Portfolio children"
|
|
|
+ )
|
|
|
+ accepted: dict[str, AcceptedDecisionRef] = {}
|
|
|
+ for child_id in root.child_task_ids:
|
|
|
+ child = ledger.tasks[child_id]
|
|
|
+ decisions = [
|
|
|
+ ledger.decisions[item]
|
|
|
+ for item in child.decision_ids
|
|
|
+ if ledger.decisions[item].action is DecisionAction.ACCEPT
|
|
|
+ ]
|
|
|
+ if len(decisions) != 1 or not decisions[0].attempt_id or not decisions[0].validation_id:
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "each Root child requires exactly one closed ACCEPT"
|
|
|
+ )
|
|
|
+ attempt = ledger.attempts[decisions[0].attempt_id]
|
|
|
+ validation = ledger.validations[decisions[0].validation_id]
|
|
|
+ if (
|
|
|
+ attempt.submission is None
|
|
|
+ or len(attempt.submission.artifact_refs) != 1
|
|
|
+ or validation.attempt_id != attempt.attempt_id
|
|
|
+ or validation.snapshot_id != attempt.snapshot_id
|
|
|
+ or validation.verdict is not ValidationVerdict.PASSED
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "Root child ACCEPT is not closed over one passed snapshot"
|
|
|
+ )
|
|
|
+ kind = task_kind(child.current_spec.context_refs)
|
|
|
+ if (
|
|
|
+ kind
|
|
|
+ not in {
|
|
|
+ ScriptTaskKind.DIRECTION.value,
|
|
|
+ ScriptTaskKind.CANDIDATE_PORTFOLIO.value,
|
|
|
+ }
|
|
|
+ or kind in accepted
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "Root children must be one Direction and one Portfolio"
|
|
|
+ )
|
|
|
+ artifact_ref = attempt.submission.artifact_refs[0]
|
|
|
+ version = await self.artifacts.read_by_ref(
|
|
|
+ artifact_ref,
|
|
|
+ script_build_id=script_build_id,
|
|
|
+ task_id=child.task_id,
|
|
|
+ attempt_id=attempt.attempt_id,
|
|
|
+ )
|
|
|
+ if version.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
|
|
|
+ raise PhaseThreeBoundaryNotReady("Root child Artifact is not frozen")
|
|
|
+ if kind == ScriptTaskKind.DIRECTION.value and not isinstance(
|
|
|
+ version.artifact, ScriptDirectionArtifactV1
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady("accepted Direction has the wrong Artifact type")
|
|
|
+ accepted[kind] = AcceptedDecisionRef(
|
|
|
+ decision_id=decisions[0].decision_id,
|
|
|
+ artifact_ref=artifact_ref,
|
|
|
+ scope_ref="script-build://scope/root",
|
|
|
+ expected_task_kind=ScriptTaskKind(kind),
|
|
|
+ )
|
|
|
+ if set(accepted) != {
|
|
|
+ ScriptTaskKind.DIRECTION.value,
|
|
|
+ ScriptTaskKind.CANDIDATE_PORTFOLIO.value,
|
|
|
+ }:
|
|
|
+ raise PhaseThreeBoundaryNotReady("Root requires accepted Direction and Portfolio")
|
|
|
+ portfolio_ref = accepted[ScriptTaskKind.CANDIDATE_PORTFOLIO.value].artifact_ref
|
|
|
+ portfolio_version = await self.artifacts.read_by_ref(
|
|
|
+ portfolio_ref, script_build_id=script_build_id
|
|
|
+ )
|
|
|
+ if not isinstance(portfolio_version.artifact, CandidatePortfolioArtifactV1):
|
|
|
+ raise PhaseThreeBoundaryNotReady("accepted Portfolio has the wrong Artifact type")
|
|
|
+ return _AcceptedRootClosure(
|
|
|
+ accepted[ScriptTaskKind.DIRECTION.value],
|
|
|
+ accepted[ScriptTaskKind.CANDIDATE_PORTFOLIO.value],
|
|
|
+ portfolio_version.artifact.adopted_structured_script_ref,
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _freeze_root_contract(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ root: Any,
|
|
|
+ closure: _AcceptedRootClosure,
|
|
|
+ snapshot_id: str,
|
|
|
+ ) -> Any:
|
|
|
+ contract = ScriptTaskContractV1(
|
|
|
+ task_kind=ScriptTaskKind.ROOT_DELIVERY,
|
|
|
+ scope_ref="script-build://scope/root",
|
|
|
+ intent_class=ScriptIntentClass.DELIVER,
|
|
|
+ objective=root.current_spec.objective,
|
|
|
+ input_decision_refs=(closure.direction, closure.portfolio),
|
|
|
+ base_artifact_ref=None,
|
|
|
+ write_scope=(),
|
|
|
+ gap_ref=None,
|
|
|
+ output_schema="root-delivery-manifest/v1",
|
|
|
+ criteria=tuple(
|
|
|
+ ScriptCriterion(item.criterion_id, item.description, item.hard)
|
|
|
+ for item in root.current_spec.acceptance_criteria
|
|
|
+ ),
|
|
|
+ budget=ScriptTaskBudget(),
|
|
|
+ )
|
|
|
+ frozen = await self.contracts.freeze(root_trace_id, contract)
|
|
|
+ # Snapshot identity is present in TaskSpec; this guards accidental use of
|
|
|
+ # a contract prepared for a different continuation.
|
|
|
+ if not snapshot_id:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three InputSnapshot is missing")
|
|
|
+ return frozen
|
|
|
+
|
|
|
+ async def _prepare_policy_messages(
|
|
|
+ self, binding: Any, snapshot: Any, policy: str, message: str
|
|
|
+ ) -> AsyncIterator[Any] | None:
|
|
|
+ events = await self.runner.trace_store.get_events(binding.root_trace_id, 0)
|
|
|
+ migrations = []
|
|
|
+ for event in events:
|
|
|
+ if (
|
|
|
+ event.get("event") != "planner_policy_migrated"
|
|
|
+ and event.get("event_type") != "planner_policy_migrated"
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ payload = event.get("data") or event.get("payload") or event
|
|
|
+ if payload.get("policy_version") == PHASE_THREE_POLICY:
|
|
|
+ migrations.append(payload)
|
|
|
+ policy_digest = canonical_sha256(policy).wire
|
|
|
+ toolset_digest = canonical_sha256(
|
|
|
+ sorted(self.runner.tools.get_tool_names(groups=["script_build"]))
|
|
|
+ ).wire
|
|
|
+ if len(migrations) > 1:
|
|
|
+ raise PhaseThreeBoundaryNotReady("multiple phase-three policy migrations exist")
|
|
|
+ if migrations:
|
|
|
+ payload = migrations[0]
|
|
|
+ expected = {
|
|
|
+ "policy_version": PHASE_THREE_POLICY,
|
|
|
+ "policy_digest": policy_digest,
|
|
|
+ "toolset_digest": toolset_digest,
|
|
|
+ "input_snapshot_id": snapshot.snapshot_id,
|
|
|
+ }
|
|
|
+ if any(str(payload.get(key)) != str(value) for key, value in expected.items()):
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three policy migration digest differs")
|
|
|
+ system_sequence, user_sequence = await self._verify_policy_messages(
|
|
|
+ binding.root_trace_id, payload, policy, message
|
|
|
+ )
|
|
|
+ if await self._has_output_after(binding.root_trace_id, user_sequence):
|
|
|
+ raise MissionRecoveryRequired()
|
|
|
+ return None
|
|
|
+ prepared = await self._find_policy_messages(binding.root_trace_id, policy, message)
|
|
|
+ if prepared is not None:
|
|
|
+ system_sequence, user_sequence = prepared
|
|
|
+ await self.runner.trace_store.append_event(
|
|
|
+ binding.root_trace_id,
|
|
|
+ "planner_policy_migrated",
|
|
|
+ {
|
|
|
+ "policy_version": PHASE_THREE_POLICY,
|
|
|
+ "policy_digest": policy_digest,
|
|
|
+ "toolset_digest": toolset_digest,
|
|
|
+ "input_snapshot_id": snapshot.snapshot_id,
|
|
|
+ "system_sequence": system_sequence,
|
|
|
+ "user_sequence": user_sequence,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if await self._has_output_after(binding.root_trace_id, user_sequence):
|
|
|
+ raise MissionRecoveryRequired()
|
|
|
+ return None
|
|
|
+ iterator = cast(
|
|
|
+ AsyncIterator[Any],
|
|
|
+ self.runner.run(
|
|
|
+ messages=[
|
|
|
+ {"role": "system", "content": policy},
|
|
|
+ {"role": "user", "content": message},
|
|
|
+ ],
|
|
|
+ config=self.factory.build_phase_three_run_config(binding, snapshot),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ try:
|
|
|
+ await anext(iterator)
|
|
|
+ system_message = await anext(iterator)
|
|
|
+ user_message = await anext(iterator)
|
|
|
+ except Exception as exc:
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "phase-three policy messages were not durable"
|
|
|
+ ) from exc
|
|
|
+ if (
|
|
|
+ getattr(system_message, "role", None) != "system"
|
|
|
+ or getattr(user_message, "role", None) != "user"
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three policy message order differs")
|
|
|
+ await self.runner.trace_store.append_event(
|
|
|
+ binding.root_trace_id,
|
|
|
+ "planner_policy_migrated",
|
|
|
+ {
|
|
|
+ "policy_version": PHASE_THREE_POLICY,
|
|
|
+ "policy_digest": policy_digest,
|
|
|
+ "toolset_digest": toolset_digest,
|
|
|
+ "input_snapshot_id": snapshot.snapshot_id,
|
|
|
+ "system_sequence": system_message.sequence,
|
|
|
+ "user_sequence": user_message.sequence,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return iterator
|
|
|
+
|
|
|
+ async def _find_policy_messages(
|
|
|
+ self, root_trace_id: str, policy: str, message: str
|
|
|
+ ) -> tuple[int, int] | None:
|
|
|
+ messages = await self.runner.trace_store.get_trace_messages(root_trace_id)
|
|
|
+ systems = [item for item in messages if item.role == "system" and item.content == policy]
|
|
|
+ users = [item for item in messages if item.role == "user" and item.content == message]
|
|
|
+ if not systems and not users:
|
|
|
+ return None
|
|
|
+ if len(systems) != 1 or len(users) != 1:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three policy messages are duplicated")
|
|
|
+ if getattr(users[0], "parent_sequence", None) != systems[0].sequence:
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three policy message parent chain differs")
|
|
|
+ return systems[0].sequence, users[0].sequence
|
|
|
+
|
|
|
+ async def _verify_policy_messages(
|
|
|
+ self,
|
|
|
+ root_trace_id: str,
|
|
|
+ payload: Any,
|
|
|
+ policy: str,
|
|
|
+ message: str,
|
|
|
+ ) -> tuple[int, int]:
|
|
|
+ system_sequence = payload.get("system_sequence")
|
|
|
+ user_sequence = payload.get("user_sequence")
|
|
|
+ if (
|
|
|
+ isinstance(system_sequence, bool)
|
|
|
+ or not isinstance(system_sequence, int)
|
|
|
+ or isinstance(user_sequence, bool)
|
|
|
+ or not isinstance(user_sequence, int)
|
|
|
+ ):
|
|
|
+ raise PhaseThreeBoundaryNotReady("phase-three policy sequence is invalid")
|
|
|
+ observed = await self._find_policy_messages(root_trace_id, policy, message)
|
|
|
+ if observed != (system_sequence, user_sequence):
|
|
|
+ raise PhaseThreeBoundaryNotReady(
|
|
|
+ "phase-three migration is not backed by its durable messages"
|
|
|
+ )
|
|
|
+ return observed
|
|
|
+
|
|
|
+ async def _has_output_after(self, root_trace_id: str, user_sequence: int) -> bool:
|
|
|
+ messages = await self.runner.trace_store.get_trace_messages(root_trace_id)
|
|
|
+ return any(
|
|
|
+ item.sequence > user_sequence and item.role not in {"system", "user"}
|
|
|
+ for item in messages
|
|
|
+ )
|
|
|
+
|
|
|
+ def _discard(self, script_build_id: int, task: asyncio.Task[None]) -> None:
|
|
|
+ if self._runs.get(script_build_id) is task:
|
|
|
+ self._runs.pop(script_build_id, None)
|
|
|
+ try:
|
|
|
+ task.exception()
|
|
|
+ except (asyncio.CancelledError, Exception):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "PHASE_THREE_POLICY",
|
|
|
+ "PHASE_THREE_ROOT_ACCEPTED",
|
|
|
+ "PhaseThreeAdvanceResult",
|
|
|
+ "PhaseThreeContinuationService",
|
|
|
+]
|