|
|
@@ -19,6 +19,11 @@ from agent.orchestration import (
|
|
|
)
|
|
|
|
|
|
from script_build_host.domain.errors import ProtocolViolation
|
|
|
+from script_build_host.domain.input_compatibility import (
|
|
|
+ AcceptedInputCompatibilityError,
|
|
|
+ AcceptedInputSource,
|
|
|
+ validate_accepted_input,
|
|
|
+)
|
|
|
from script_build_host.domain.phase_two_ports import ScriptTaskContractStore
|
|
|
from script_build_host.domain.ports import MissionBindingRepository
|
|
|
from script_build_host.domain.task_contracts import (
|
|
|
@@ -125,6 +130,207 @@ class PlannedContract:
|
|
|
draft: dict[str, Any]
|
|
|
|
|
|
|
|
|
+class PhasePolicyGuard:
|
|
|
+ """Enforce Host-protected phase ownership before framework mutations."""
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def phase(context: Mapping[str, Any]) -> int | None:
|
|
|
+ value = context.get("phase")
|
|
|
+ if value is None:
|
|
|
+ # Direct application-service callers created before protected phase
|
|
|
+ # context remain supported. Agent runs always carry this value.
|
|
|
+ return None
|
|
|
+ if isinstance(value, bool) or value not in {1, 2, 3}:
|
|
|
+ raise TaskContractError("PHASE_POLICY_VIOLATION", "protected phase is invalid")
|
|
|
+ return int(value)
|
|
|
+
|
|
|
+ def contracts(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ ledger: Any,
|
|
|
+ parent: Any,
|
|
|
+ contracts: Sequence[ScriptTaskContractV1],
|
|
|
+ ) -> None:
|
|
|
+ phase = self.phase(context)
|
|
|
+ if phase is None:
|
|
|
+ return
|
|
|
+ parent_is_root = parent.task_id == ledger.root_task_id
|
|
|
+ parent_kind = task_kind_from_context_refs(parent.current_spec.context_refs)
|
|
|
+ kinds = {item.task_kind for item in contracts}
|
|
|
+ if phase == 1:
|
|
|
+ allowed = (
|
|
|
+ {ScriptTaskKind.DIRECTION}
|
|
|
+ if parent_is_root
|
|
|
+ else (
|
|
|
+ set(ScriptTaskKind(item) for item in RETRIEVAL_KINDS)
|
|
|
+ if parent_kind == ScriptTaskKind.DIRECTION.value
|
|
|
+ else set()
|
|
|
+ )
|
|
|
+ )
|
|
|
+ elif phase == 2:
|
|
|
+ allowed = (
|
|
|
+ {ScriptTaskKind.CANDIDATE_PORTFOLIO} if parent_is_root else set(_PHASE_TWO_KINDS)
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ allowed = set()
|
|
|
+ if not kinds <= allowed:
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ f"phase {phase} cannot plan {sorted(item.value for item in kinds)} here",
|
|
|
+ )
|
|
|
+ if phase == 2:
|
|
|
+ self._phase_two_contracts(contracts)
|
|
|
+
|
|
|
+ def dispatch(self, *, context: Mapping[str, Any], kind: ScriptTaskKind) -> None:
|
|
|
+ phase = self.phase(context)
|
|
|
+ if phase is None:
|
|
|
+ return
|
|
|
+ allowed = {
|
|
|
+ 1: {ScriptTaskKind.DIRECTION, *(ScriptTaskKind(item) for item in RETRIEVAL_KINDS)},
|
|
|
+ 2: set(_PHASE_TWO_KINDS),
|
|
|
+ 3: {ScriptTaskKind.ROOT_DELIVERY},
|
|
|
+ }[phase]
|
|
|
+ if kind not in allowed:
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ f"phase {phase} cannot dispatch {kind.value}",
|
|
|
+ )
|
|
|
+
|
|
|
+ def revisions(
|
|
|
+ self, *, context: Mapping[str, Any], contracts: Sequence[ScriptTaskContractV1]
|
|
|
+ ) -> None:
|
|
|
+ phase = self.phase(context)
|
|
|
+ if phase is None:
|
|
|
+ return
|
|
|
+ allowed = {
|
|
|
+ 1: {ScriptTaskKind.DIRECTION, *(ScriptTaskKind(item) for item in RETRIEVAL_KINDS)},
|
|
|
+ 2: set(_PHASE_TWO_KINDS),
|
|
|
+ 3: set(),
|
|
|
+ }[phase]
|
|
|
+ kinds = {item.task_kind for item in contracts}
|
|
|
+ if not kinds <= allowed:
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ f"phase {phase} cannot revise {sorted(item.value for item in kinds)}",
|
|
|
+ )
|
|
|
+ if phase == 2:
|
|
|
+ self._phase_two_contracts(contracts)
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _phase_two_contracts(contracts: Sequence[ScriptTaskContractV1]) -> None:
|
|
|
+ container_kinds = {
|
|
|
+ ScriptTaskKind.CANDIDATE_PORTFOLIO,
|
|
|
+ ScriptTaskKind.COMPOSE,
|
|
|
+ }
|
|
|
+ required_write_prefixes = {
|
|
|
+ ScriptTaskKind.STRUCTURE: "script-build://writes/paragraphs/",
|
|
|
+ ScriptTaskKind.PARAGRAPH: "script-build://writes/paragraphs/",
|
|
|
+ ScriptTaskKind.ELEMENT_SET: "script-build://writes/elements/",
|
|
|
+ }
|
|
|
+ for contract in contracts:
|
|
|
+ adoption_started = bool(
|
|
|
+ contract.candidate_closure_decision_refs
|
|
|
+ or contract.adopted_decision_ids
|
|
|
+ or contract.held_or_rejected_decision_ids
|
|
|
+ or contract.compose_order
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ contract.task_kind
|
|
|
+ in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}
|
|
|
+ and adoption_started
|
|
|
+ and not contract.execution_ready
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "TASK_CONTRACT_NOT_EXECUTABLE",
|
|
|
+ "an adoption revision must close every candidate and set compose_order "
|
|
|
+ "to the adopted Decision IDs",
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ contract.task_kind in container_kinds
|
|
|
+ and "script-build://writes" not in contract.write_scope
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "WRITE_SCOPE_VIOLATION",
|
|
|
+ f"{contract.task_kind.value} requires write_scope "
|
|
|
+ "script-build://writes so child capabilities remain contained",
|
|
|
+ )
|
|
|
+ prefix = required_write_prefixes.get(contract.task_kind)
|
|
|
+ if prefix is not None and not any(
|
|
|
+ value == prefix.removesuffix("/") or value.startswith(prefix)
|
|
|
+ for value in contract.write_scope
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "WRITE_SCOPE_VIOLATION",
|
|
|
+ f"{contract.task_kind.value} requires write_scope under {prefix}",
|
|
|
+ )
|
|
|
+ if contract.task_kind is ScriptTaskKind.PARAGRAPH:
|
|
|
+ structures = tuple(
|
|
|
+ item
|
|
|
+ for item in contract.input_decision_refs
|
|
|
+ if item.expected_task_kind is ScriptTaskKind.STRUCTURE
|
|
|
+ )
|
|
|
+ if not structures:
|
|
|
+ raise TaskContractError(
|
|
|
+ "INPUT_SCOPE_MISMATCH",
|
|
|
+ "Paragraph requires the accepted Structure decision in "
|
|
|
+ "input_decision_refs",
|
|
|
+ )
|
|
|
+ if (
|
|
|
+ contract.base_artifact_ref is None
|
|
|
+ or all(
|
|
|
+ contract.base_artifact_ref != item.artifact_ref
|
|
|
+ for item in structures
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "INPUT_SCOPE_MISMATCH",
|
|
|
+ "Paragraph must copy the accepted Structure artifact_ref unchanged "
|
|
|
+ "into base_artifact_ref",
|
|
|
+ )
|
|
|
+ if contract.task_kind is ScriptTaskKind.COMPOSE:
|
|
|
+ directions = tuple(
|
|
|
+ item
|
|
|
+ for item in contract.input_decision_refs
|
|
|
+ if item.expected_task_kind is ScriptTaskKind.DIRECTION
|
|
|
+ )
|
|
|
+ if contract.execution_ready and len(directions) != 1:
|
|
|
+ raise TaskContractError(
|
|
|
+ "CHILD_DECISION_INVALID",
|
|
|
+ "Compose requires exactly one accepted Direction in "
|
|
|
+ "input_decision_refs",
|
|
|
+ )
|
|
|
+ by_id = {
|
|
|
+ item.decision_id: item
|
|
|
+ for item in contract.candidate_closure_decision_refs
|
|
|
+ }
|
|
|
+ adopted = tuple(
|
|
|
+ by_id[item]
|
|
|
+ for item in contract.adopted_decision_ids
|
|
|
+ if item in by_id
|
|
|
+ )
|
|
|
+ if any(
|
|
|
+ item.expected_task_kind is ScriptTaskKind.PARAGRAPH for item in adopted
|
|
|
+ ) and not any(
|
|
|
+ item.expected_task_kind is ScriptTaskKind.STRUCTURE for item in adopted
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "INPUT_SCOPE_MISMATCH",
|
|
|
+ "Compose adopting Paragraph must also adopt its covering Structure",
|
|
|
+ )
|
|
|
+
|
|
|
+ def boundary(self, *, context: Mapping[str, Any], reason: str) -> None:
|
|
|
+ phase = self.phase(context)
|
|
|
+ if phase is None:
|
|
|
+ return
|
|
|
+ expected = {1: PHASE_ONE_CAPABILITY_BOUNDARY, 2: PHASE_TWO_CANDIDATE_PORTFOLIO_READY}
|
|
|
+ if phase not in expected or reason != expected[phase]:
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ f"phase {phase} cannot submit Root boundary {reason}",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
class PhaseTwoPlanningService:
|
|
|
"""Controlled Planner surface for Phase1 and Phase2 Tasks."""
|
|
|
|
|
|
@@ -146,6 +352,7 @@ class PhaseTwoPlanningService:
|
|
|
self.closure_gate = closure_gate
|
|
|
self.workspace_lifecycle = workspace_lifecycle
|
|
|
self.limits = limits or PhaseTwoLimits()
|
|
|
+ self.phase_policy = PhasePolicyGuard()
|
|
|
|
|
|
async def plan_script_tasks(
|
|
|
self,
|
|
|
@@ -181,6 +388,12 @@ class PhaseTwoPlanningService:
|
|
|
|
|
|
# Parse every value before writing any content-addressed blob.
|
|
|
parsed_contracts = tuple(ScriptTaskContractV1.from_payload(value) for value in supplied)
|
|
|
+ self.phase_policy.contracts(
|
|
|
+ context=context, ledger=ledger, parent=parent, contracts=parsed_contracts
|
|
|
+ )
|
|
|
+ await self._guard_phase_two_direction_scope(
|
|
|
+ context, root_trace_id, ledger, parsed_contracts
|
|
|
+ )
|
|
|
await self._guard_task_budget(root_trace_id, ledger, parsed_contracts, parent)
|
|
|
await self._guard_hierarchy(root_trace_id, ledger, parent, parsed_contracts)
|
|
|
await self._guard_supersessions(root_trace_id, ledger, parsed_contracts)
|
|
|
@@ -293,11 +506,15 @@ class PhaseTwoPlanningService:
|
|
|
"TASK_CONTRACT_INVALID", "REVISE requires one complete replacement contract"
|
|
|
)
|
|
|
contract = ScriptTaskContractV1.from_payload(replacement_contract)
|
|
|
+ self.phase_policy.revisions(context=context, contracts=(contract,))
|
|
|
current = await self.contract_for_task(root_trace_id, task)
|
|
|
if contract.task_kind is not current.contract.task_kind:
|
|
|
raise TaskContractError(
|
|
|
"TASK_KIND_PRESET_MISMATCH", "REVISE cannot change the Task kind"
|
|
|
)
|
|
|
+ await self._guard_phase_two_direction_scope(
|
|
|
+ context, root_trace_id, ledger, (contract,)
|
|
|
+ )
|
|
|
await self._guard_supersessions(root_trace_id, ledger, (contract,))
|
|
|
await self._guard_accepted_input_scopes(root_trace_id, ledger, (contract,))
|
|
|
planned_replacement = (
|
|
|
@@ -310,6 +527,12 @@ class PhaseTwoPlanningService:
|
|
|
"TASK_CONTRACT_INVALID", "SPLIT requires complete child contracts"
|
|
|
)
|
|
|
contracts = tuple(ScriptTaskContractV1.from_payload(value) for value in child_contracts)
|
|
|
+ self.phase_policy.contracts(
|
|
|
+ context=context, ledger=ledger, parent=task, contracts=contracts
|
|
|
+ )
|
|
|
+ await self._guard_phase_two_direction_scope(
|
|
|
+ context, root_trace_id, ledger, contracts
|
|
|
+ )
|
|
|
await self._guard_task_budget(root_trace_id, ledger, contracts, task)
|
|
|
await self._guard_hierarchy(root_trace_id, ledger, task, contracts)
|
|
|
await self._guard_supersessions(root_trace_id, ledger, contracts)
|
|
|
@@ -324,6 +547,7 @@ class PhaseTwoPlanningService:
|
|
|
)
|
|
|
|
|
|
if decision_action is DecisionAction.BLOCK and is_root:
|
|
|
+ self.phase_policy.boundary(context=context, reason=bounded_reason)
|
|
|
if bounded_reason == PHASE_ONE_CAPABILITY_BOUNDARY:
|
|
|
if not phase_one_ready:
|
|
|
raise TaskContractError(
|
|
|
@@ -420,6 +644,7 @@ class PhaseTwoPlanningService:
|
|
|
)
|
|
|
frozen = await self.contract_for_task(root_trace_id, task)
|
|
|
contract = frozen.contract
|
|
|
+ self.phase_policy.dispatch(context=context, kind=contract.task_kind)
|
|
|
if len(task.attempt_ids) >= min(
|
|
|
contract.budget.max_attempts, self.limits.max_attempts_per_task
|
|
|
):
|
|
|
@@ -742,6 +967,50 @@ class PhaseTwoPlanningService:
|
|
|
"replacement and superseded scopes are incompatible",
|
|
|
)
|
|
|
|
|
|
+ async def _guard_phase_two_direction_scope(
|
|
|
+ self,
|
|
|
+ context: Mapping[str, Any],
|
|
|
+ root_trace_id: str,
|
|
|
+ ledger: Any,
|
|
|
+ contracts: Sequence[ScriptTaskContractV1],
|
|
|
+ ) -> None:
|
|
|
+ """Keep the Phase2 business tree inside its accepted Direction scope."""
|
|
|
+
|
|
|
+ if self.phase_policy.phase(context) != 2 or not any(
|
|
|
+ item.task_kind is ScriptTaskKind.CANDIDATE_PORTFOLIO for item in contracts
|
|
|
+ ):
|
|
|
+ return
|
|
|
+ direction_scopes: list[str] = []
|
|
|
+ for task in ledger.tasks.values():
|
|
|
+ if (
|
|
|
+ task_kind_from_context_refs(task.current_spec.context_refs)
|
|
|
+ != ScriptTaskKind.DIRECTION.value
|
|
|
+ or task.status is not TaskStatus.COMPLETED
|
|
|
+ or not task.decision_ids
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ decision = ledger.decisions.get(task.decision_ids[-1])
|
|
|
+ if decision is None or decision.action is not DecisionAction.ACCEPT:
|
|
|
+ continue
|
|
|
+ direction_scopes.append(
|
|
|
+ (await self.contract_for_task(root_trace_id, task)).contract.scope_ref
|
|
|
+ )
|
|
|
+ if len(direction_scopes) != 1:
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ "Phase2 requires exactly one accepted Direction scope",
|
|
|
+ )
|
|
|
+ direction_scope = direction_scopes[0]
|
|
|
+ if any(
|
|
|
+ item.task_kind is ScriptTaskKind.CANDIDATE_PORTFOLIO
|
|
|
+ and not _scope_contains(direction_scope, item.scope_ref)
|
|
|
+ for item in contracts
|
|
|
+ ):
|
|
|
+ raise TaskContractError(
|
|
|
+ "INPUT_SCOPE_MISMATCH",
|
|
|
+ "CandidatePortfolio scope must stay below the accepted Direction scope",
|
|
|
+ )
|
|
|
+
|
|
|
async def _guard_accepted_input_scopes(
|
|
|
self,
|
|
|
root_trace_id: str,
|
|
|
@@ -790,15 +1059,19 @@ class PhaseTwoPlanningService:
|
|
|
"INPUT_SCOPE_MISMATCH",
|
|
|
"an immutable input's declared scope differs from its producer scope",
|
|
|
)
|
|
|
- if not (
|
|
|
- _scope_contains(consumer.scope_ref, producer.scope_ref)
|
|
|
- or _scope_contains(producer.scope_ref, consumer.scope_ref)
|
|
|
- ):
|
|
|
+ try:
|
|
|
+ validate_accepted_input(
|
|
|
+ consumer_kind=consumer.task_kind,
|
|
|
+ consumer_scope=consumer.scope_ref,
|
|
|
+ producer_kind=producer.task_kind,
|
|
|
+ producer_scope=producer.scope_ref,
|
|
|
+ source=AcceptedInputSource.EXPLICIT,
|
|
|
+ )
|
|
|
+ except AcceptedInputCompatibilityError as exc:
|
|
|
raise TaskContractError(
|
|
|
"INPUT_SCOPE_MISMATCH",
|
|
|
- f"{consumer.task_kind.value} scope must equal or nest within accepted "
|
|
|
- f"{producer.task_kind.value} scope {producer.scope_ref}",
|
|
|
- )
|
|
|
+ str(exc),
|
|
|
+ ) from exc
|
|
|
|
|
|
async def _guard_runtime_budget(
|
|
|
self, ledger: Any, *, external_query_mode: str = "strict"
|