|
|
@@ -7,15 +7,18 @@ checks script-build invariants before calling Coordinator operations.
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
+import hashlib
|
|
|
import json
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
from dataclasses import dataclass, replace
|
|
|
from datetime import UTC, datetime
|
|
|
from typing import Any, Protocol, cast
|
|
|
+from uuid import NAMESPACE_URL, uuid4, uuid5
|
|
|
|
|
|
from agent.orchestration import (
|
|
|
DecisionAction,
|
|
|
OperationStatus,
|
|
|
+ TaskConflict,
|
|
|
TaskStatus,
|
|
|
ValidationVerdict,
|
|
|
)
|
|
|
@@ -45,6 +48,7 @@ from script_build_host.domain.task_contracts import (
|
|
|
output_schema_for,
|
|
|
task_kind_from_context_refs,
|
|
|
)
|
|
|
+from script_build_host.domain.task_policy import TaskPolicyCatalog
|
|
|
|
|
|
TASK_KIND_REF_PREFIX = "script-build://task-kinds/"
|
|
|
TASK_CONTRACT_REF_PREFIX = "script-build://task-contracts/sha256/"
|
|
|
@@ -196,9 +200,12 @@ class PhasePolicyGuard:
|
|
|
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",
|
|
|
+ self._raise_planning_violation(
|
|
|
+ phase=phase,
|
|
|
+ parent_is_root=parent_is_root,
|
|
|
+ parent_kind=parent_kind,
|
|
|
+ attempted=kinds,
|
|
|
+ allowed=allowed,
|
|
|
)
|
|
|
if phase == 1 and any(item.goal_ids for item in contracts):
|
|
|
raise TaskContractError(
|
|
|
@@ -235,22 +242,56 @@ class PhasePolicyGuard:
|
|
|
)
|
|
|
elif phase == 2:
|
|
|
allowed = (
|
|
|
- {ScriptTaskKind.CANDIDATE_PORTFOLIO}
|
|
|
- if parent_is_root
|
|
|
- else set(_PHASE_TWO_KINDS)
|
|
|
+ {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",
|
|
|
+ self._raise_planning_violation(
|
|
|
+ phase=phase,
|
|
|
+ parent_is_root=parent_is_root,
|
|
|
+ parent_kind=parent_kind,
|
|
|
+ attempted=kinds,
|
|
|
+ allowed=allowed,
|
|
|
)
|
|
|
if phase == 1 and any(item.goal_ids for item in inputs):
|
|
|
raise TaskContractError(
|
|
|
"GOAL_SCOPE_INVALID", "Phase1 contracts must use an empty goal_ids array"
|
|
|
)
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _raise_planning_violation(
|
|
|
+ *,
|
|
|
+ phase: int,
|
|
|
+ parent_is_root: bool,
|
|
|
+ parent_kind: str | None,
|
|
|
+ attempted: set[ScriptTaskKind],
|
|
|
+ allowed: set[ScriptTaskKind],
|
|
|
+ ) -> None:
|
|
|
+ attempted_values = sorted(item.value for item in attempted)
|
|
|
+ allowed_values = sorted(item.value for item in allowed)
|
|
|
+ if phase == 2 and parent_is_root:
|
|
|
+ summary = (
|
|
|
+ "phase 2 Root must first plan exactly one candidate-portfolio; "
|
|
|
+ f"received {attempted_values}"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ summary = (
|
|
|
+ f"phase {phase} cannot plan {attempted_values} here; "
|
|
|
+ f"allowed task kinds are {allowed_values}"
|
|
|
+ )
|
|
|
+ raise TaskContractError(
|
|
|
+ "PHASE_POLICY_VIOLATION",
|
|
|
+ summary,
|
|
|
+ details={
|
|
|
+ "phase": phase,
|
|
|
+ "operation": "plan",
|
|
|
+ "parent_kind": "root" if parent_is_root else parent_kind,
|
|
|
+ "attempted_task_kinds": attempted_values,
|
|
|
+ "allowed_task_kinds": allowed_values,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
def dispatch(self, *, context: Mapping[str, Any], kind: ScriptTaskKind) -> None:
|
|
|
phase = self.phase(context)
|
|
|
if phase is None:
|
|
|
@@ -309,8 +350,7 @@ class PhasePolicyGuard:
|
|
|
or contract.compose_order
|
|
|
)
|
|
|
if (
|
|
|
- contract.task_kind
|
|
|
- in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}
|
|
|
+ contract.task_kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}
|
|
|
and adoption_started
|
|
|
and not contract.execution_ready
|
|
|
):
|
|
|
@@ -349,12 +389,8 @@ class PhasePolicyGuard:
|
|
|
"Paragraph patch requires the accepted Structure decision in "
|
|
|
"input_decision_refs",
|
|
|
)
|
|
|
- if (
|
|
|
- contract.base_artifact_ref is not None
|
|
|
- and all(
|
|
|
- contract.base_artifact_ref != item.artifact_ref
|
|
|
- for item in structures
|
|
|
- )
|
|
|
+ if contract.base_artifact_ref is not None and all(
|
|
|
+ contract.base_artifact_ref != item.artifact_ref for item in structures
|
|
|
):
|
|
|
raise TaskContractError(
|
|
|
"INPUT_SCOPE_MISMATCH",
|
|
|
@@ -370,17 +406,13 @@ class PhasePolicyGuard:
|
|
|
if contract.execution_ready and len(directions) != 1:
|
|
|
raise TaskContractError(
|
|
|
"CHILD_DECISION_INVALID",
|
|
|
- "Compose requires exactly one accepted Direction in "
|
|
|
- "input_decision_refs",
|
|
|
+ "Compose requires exactly one accepted Direction in input_decision_refs",
|
|
|
)
|
|
|
by_id = {
|
|
|
- item.decision_id: item
|
|
|
- for item in contract.candidate_closure_decision_refs
|
|
|
+ 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
|
|
|
+ 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
|
|
|
@@ -428,6 +460,7 @@ class PhaseTwoPlanningService:
|
|
|
self.workspace_lifecycle = workspace_lifecycle
|
|
|
self.limits = limits or PhaseTwoLimits()
|
|
|
self.phase_policy = PhasePolicyGuard()
|
|
|
+ self.task_policy = TaskPolicyCatalog()
|
|
|
|
|
|
async def plan_script_tasks(
|
|
|
self,
|
|
|
@@ -463,6 +496,10 @@ class PhaseTwoPlanningService:
|
|
|
|
|
|
# Resolve model-owned semantics before writing any content-addressed blob.
|
|
|
planner_inputs = tuple(PlannerTaskInput.from_payload(value) for value in supplied)
|
|
|
+ planned_task_ids = tuple(
|
|
|
+ _planned_task_id(root_trace_id, _optional(context, "tool_call_id"), index)
|
|
|
+ for index in range(len(planner_inputs))
|
|
|
+ )
|
|
|
self.phase_policy.planner_inputs(
|
|
|
context=context,
|
|
|
ledger=ledger,
|
|
|
@@ -473,11 +510,13 @@ class PhaseTwoPlanningService:
|
|
|
[
|
|
|
await self._resolve_planner_input(
|
|
|
value,
|
|
|
+ task_id=planned_task_ids[index],
|
|
|
+ parent=parent,
|
|
|
binding=binding,
|
|
|
ledger=ledger,
|
|
|
root_trace_id=root_trace_id,
|
|
|
)
|
|
|
- for value in planner_inputs
|
|
|
+ for index, value in enumerate(planner_inputs)
|
|
|
]
|
|
|
)
|
|
|
self.phase_policy.contracts(
|
|
|
@@ -507,6 +546,8 @@ class PhaseTwoPlanningService:
|
|
|
planned = await self._freeze_all(
|
|
|
root_trace_id, str(binding.input_snapshot_id), parsed_contracts
|
|
|
)
|
|
|
+ for item, planned_task_id in zip(planned, planned_task_ids, strict=True):
|
|
|
+ item.draft["_task_id"] = planned_task_id
|
|
|
result = await self.coordinator.create_tasks(
|
|
|
root_trace_id,
|
|
|
[item.draft for item in planned],
|
|
|
@@ -521,7 +562,10 @@ class PhaseTwoPlanningService:
|
|
|
"contracts": [
|
|
|
{
|
|
|
"task_kind": item.frozen.contract.task_kind.value,
|
|
|
- "scope_ref": item.frozen.contract.scope_ref,
|
|
|
+ "scope_selector": {
|
|
|
+ "anchor": "mission",
|
|
|
+ "path": list(_scope_path(item.frozen.contract.scope_ref)),
|
|
|
+ },
|
|
|
"contract_ref": item.frozen.uri,
|
|
|
"contract_digest": item.frozen.digest,
|
|
|
}
|
|
|
@@ -623,9 +667,7 @@ class PhaseTwoPlanningService:
|
|
|
"TASK_CONTRACT_INVALID",
|
|
|
"container REVISE requires selected_decision_ids only",
|
|
|
)
|
|
|
- self.phase_policy.revisions(
|
|
|
- context=context, contracts=(current.contract,)
|
|
|
- )
|
|
|
+ self.phase_policy.revisions(context=context, contracts=(current.contract,))
|
|
|
contract = await self._adoption_contract(
|
|
|
binding=binding,
|
|
|
ledger=ledger,
|
|
|
@@ -654,6 +696,8 @@ class PhaseTwoPlanningService:
|
|
|
)
|
|
|
contract = await self._resolve_planner_input(
|
|
|
planner_input,
|
|
|
+ task_id=task.task_id,
|
|
|
+ parent=parent,
|
|
|
binding=binding,
|
|
|
ledger=ledger,
|
|
|
root_trace_id=root_trace_id,
|
|
|
@@ -663,9 +707,7 @@ class PhaseTwoPlanningService:
|
|
|
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_phase_two_direction_scope(context, root_trace_id, ledger, (contract,))
|
|
|
parent = ledger.tasks.get(task.parent_task_id or "")
|
|
|
await self._guard_phase_two_goals(
|
|
|
context=context,
|
|
|
@@ -689,6 +731,10 @@ class PhaseTwoPlanningService:
|
|
|
planner_inputs = tuple(
|
|
|
PlannerTaskInput.from_payload(value) for value in child_contracts
|
|
|
)
|
|
|
+ planned_task_ids = tuple(
|
|
|
+ _planned_task_id(root_trace_id, _optional(context, "tool_call_id"), index)
|
|
|
+ for index in range(len(planner_inputs))
|
|
|
+ )
|
|
|
self.phase_policy.planner_inputs(
|
|
|
context=context,
|
|
|
ledger=ledger,
|
|
|
@@ -699,19 +745,19 @@ class PhaseTwoPlanningService:
|
|
|
[
|
|
|
await self._resolve_planner_input(
|
|
|
value,
|
|
|
+ task_id=planned_task_ids[index],
|
|
|
+ parent=task,
|
|
|
binding=binding,
|
|
|
ledger=ledger,
|
|
|
root_trace_id=root_trace_id,
|
|
|
)
|
|
|
- for value in planner_inputs
|
|
|
+ for index, value in enumerate(planner_inputs)
|
|
|
]
|
|
|
)
|
|
|
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_phase_two_direction_scope(context, root_trace_id, ledger, contracts)
|
|
|
await self._guard_phase_two_goals(
|
|
|
context=context,
|
|
|
binding=binding,
|
|
|
@@ -727,12 +773,19 @@ class PhaseTwoPlanningService:
|
|
|
planned_children = await self._freeze_all(
|
|
|
root_trace_id, str(binding.input_snapshot_id), contracts
|
|
|
)
|
|
|
+ for item, planned_task_id in zip(planned_children, planned_task_ids, strict=True):
|
|
|
+ item.draft["_task_id"] = planned_task_id
|
|
|
payload["tasks"] = [item.draft for item in planned_children]
|
|
|
elif replacement_contract is not None or child_contracts or selected_decision_ids:
|
|
|
raise TaskContractError(
|
|
|
"TASK_CONTRACT_INVALID", "this action does not accept Task contracts"
|
|
|
)
|
|
|
|
|
|
+ # The current Validation is durable Task state, not a creative choice.
|
|
|
+ # Bind it here when the Planner omits the mechanical identifier.
|
|
|
+ if validation_id is None and task.status is TaskStatus.AWAITING_DECISION:
|
|
|
+ validation_id = task.validation_ids[-1] if task.validation_ids else None
|
|
|
+
|
|
|
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:
|
|
|
@@ -756,17 +809,20 @@ class PhaseTwoPlanningService:
|
|
|
root_trace_id=root_trace_id,
|
|
|
input_snapshot_id=str(binding.input_snapshot_id),
|
|
|
)
|
|
|
- result = cast(
|
|
|
- dict[str, Any],
|
|
|
- await self.coordinator.decide_task(
|
|
|
- root_trace_id,
|
|
|
- task_id,
|
|
|
- validation_id,
|
|
|
- decision_action,
|
|
|
- payload,
|
|
|
- _optional(context, "tool_call_id"),
|
|
|
- ),
|
|
|
- )
|
|
|
+ try:
|
|
|
+ result = cast(
|
|
|
+ dict[str, Any],
|
|
|
+ await self.coordinator.decide_task(
|
|
|
+ root_trace_id,
|
|
|
+ task_id,
|
|
|
+ validation_id,
|
|
|
+ decision_action,
|
|
|
+ payload,
|
|
|
+ _optional(context, "tool_call_id"),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ except TaskConflict as exc:
|
|
|
+ raise TaskContractError("TASK_STATE_CONFLICT", str(exc)) from exc
|
|
|
if not is_root and decision_action in {
|
|
|
DecisionAction.REPAIR,
|
|
|
DecisionAction.RETRY,
|
|
|
@@ -832,6 +888,12 @@ 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)
|
|
|
+ self._guard_direction_dispatch(
|
|
|
+ ledger,
|
|
|
+ task,
|
|
|
+ contract.task_kind,
|
|
|
+ phase=self.phase_policy.phase(context),
|
|
|
+ )
|
|
|
parent = ledger.tasks.get(task.parent_task_id or "")
|
|
|
await self._guard_phase_two_goals(
|
|
|
context=context,
|
|
|
@@ -929,23 +991,33 @@ class PhaseTwoPlanningService:
|
|
|
) -> dict[str, Any]:
|
|
|
root_trace_id = _required(context, "root_trace_id")
|
|
|
ledger = await self.coordinator.task_store.load(root_trace_id)
|
|
|
- tasks: list[dict[str, Any]] = []
|
|
|
+ active_tasks: list[dict[str, Any]] = []
|
|
|
+ accepted_decisions: list[dict[str, Any]] = []
|
|
|
+ counts_by_status: dict[str, int] = {}
|
|
|
for task in sorted(ledger.tasks.values(), key=lambda item: item.display_path):
|
|
|
if task_id is not None and task.task_id != task_id:
|
|
|
continue
|
|
|
+ counts_by_status[task.status.value] = counts_by_status.get(task.status.value, 0) + 1
|
|
|
if task.task_id == ledger.root_task_id:
|
|
|
kind_value = "root"
|
|
|
- contract_ref = None
|
|
|
scope_ref = None
|
|
|
else:
|
|
|
frozen = await self.contract_for_task(root_trace_id, task)
|
|
|
kind_value = frozen.contract.task_kind.value
|
|
|
- contract_ref = frozen.uri
|
|
|
scope_ref = frozen.contract.scope_ref
|
|
|
- accepted_decision_ref: dict[str, Any] | None = None
|
|
|
- if task.task_id != ledger.root_task_id and task.decision_ids:
|
|
|
- decision_id = task.decision_ids[-1]
|
|
|
- decision = ledger.decisions.get(decision_id)
|
|
|
+ accepted_decision_id = next(
|
|
|
+ (
|
|
|
+ decision_id
|
|
|
+ for decision_id in reversed(task.decision_ids)
|
|
|
+ if (
|
|
|
+ ledger.decisions.get(decision_id) is not None
|
|
|
+ and ledger.decisions[decision_id].action is DecisionAction.ACCEPT
|
|
|
+ )
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if task.task_id != ledger.root_task_id and accepted_decision_id is not None:
|
|
|
+ decision = ledger.decisions.get(accepted_decision_id)
|
|
|
attempt = (
|
|
|
ledger.attempts.get(decision.attempt_id)
|
|
|
if decision is not None and decision.attempt_id is not None
|
|
|
@@ -956,42 +1028,59 @@ class PhaseTwoPlanningService:
|
|
|
if attempt is not None and attempt.submission is not None
|
|
|
else []
|
|
|
)
|
|
|
- if (
|
|
|
- decision is not None
|
|
|
- and decision.action is DecisionAction.ACCEPT
|
|
|
- and len(refs) == 1
|
|
|
- ):
|
|
|
+ if decision is not None and len(refs) == 1:
|
|
|
ref = refs[0]
|
|
|
- accepted_decision_ref = {
|
|
|
- "decision_id": decision_id,
|
|
|
- "artifact_ref": {
|
|
|
- "uri": ref.uri,
|
|
|
- "kind": ref.kind,
|
|
|
- "version": ref.version,
|
|
|
- "digest": ref.digest,
|
|
|
- "summary": ref.summary,
|
|
|
- "metadata": ref.metadata,
|
|
|
- },
|
|
|
+ accepted_decisions.append(
|
|
|
+ {
|
|
|
+ "decision_id": accepted_decision_id,
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "task_kind": kind_value,
|
|
|
+ "artifact_kind": ref.kind,
|
|
|
+ "artifact_ref": ref.uri,
|
|
|
+ "scope_ref": scope_ref,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ if task.task_id == ledger.root_task_id or task.status not in {
|
|
|
+ TaskStatus.COMPLETED,
|
|
|
+ TaskStatus.SUPERSEDED,
|
|
|
+ TaskStatus.CANCELLED,
|
|
|
+ }:
|
|
|
+ latest_attempt = (
|
|
|
+ ledger.attempts.get(task.attempt_ids[-1]) if task.attempt_ids else None
|
|
|
+ )
|
|
|
+ failure = (
|
|
|
+ latest_attempt.failure.to_dict()
|
|
|
+ if latest_attempt is not None and latest_attempt.failure is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ active_tasks.append(
|
|
|
+ {
|
|
|
+ "task_id": task.task_id,
|
|
|
+ "parent_task_id": task.parent_task_id,
|
|
|
+ "display_path": task.display_path,
|
|
|
+ "status": task.status.value,
|
|
|
+ "task_kind": kind_value,
|
|
|
"scope_ref": scope_ref,
|
|
|
- "expected_task_kind": kind_value,
|
|
|
+ "objective": task.current_spec.objective,
|
|
|
+ "attempt_count": len(task.attempt_ids),
|
|
|
+ "blocked_reason": task.blocked_reason,
|
|
|
+ "latest_failure": failure,
|
|
|
}
|
|
|
- tasks.append(
|
|
|
- {
|
|
|
- "task_id": task.task_id,
|
|
|
- "parent_task_id": task.parent_task_id,
|
|
|
- "display_path": task.display_path,
|
|
|
- "status": task.status.value,
|
|
|
- "task_kind": kind_value,
|
|
|
- "scope_ref": scope_ref,
|
|
|
- "contract_ref": contract_ref,
|
|
|
- "current_spec_version": task.current_spec_version,
|
|
|
- "attempt_count": len(task.attempt_ids),
|
|
|
- "child_task_ids": list(task.child_task_ids),
|
|
|
- "blocked_reason": task.blocked_reason,
|
|
|
- "accepted_decision_ref": accepted_decision_ref,
|
|
|
- }
|
|
|
- )
|
|
|
- return {"root_trace_id": root_trace_id, "revision": ledger.revision, "tasks": tasks}
|
|
|
+ )
|
|
|
+ root = ledger.tasks[ledger.root_task_id]
|
|
|
+ return {
|
|
|
+ "root_trace_id": root_trace_id,
|
|
|
+ "revision": ledger.revision,
|
|
|
+ "focused_task_id": ledger.focused_task_id,
|
|
|
+ "root": {
|
|
|
+ "task_id": root.task_id,
|
|
|
+ "status": root.status.value,
|
|
|
+ "blocked_reason": root.blocked_reason,
|
|
|
+ },
|
|
|
+ "active_tasks": active_tasks,
|
|
|
+ "accepted_decisions": accepted_decisions,
|
|
|
+ "counts_by_status": counts_by_status,
|
|
|
+ }
|
|
|
|
|
|
async def contract_for_task(self, root_trace_id: str, task: Any) -> FrozenTaskContract:
|
|
|
refs = [
|
|
|
@@ -1030,6 +1119,8 @@ class PhaseTwoPlanningService:
|
|
|
self,
|
|
|
value: PlannerTaskInput,
|
|
|
*,
|
|
|
+ task_id: str,
|
|
|
+ parent: Any,
|
|
|
binding: Any,
|
|
|
ledger: Any,
|
|
|
root_trace_id: str,
|
|
|
@@ -1046,6 +1137,7 @@ class PhaseTwoPlanningService:
|
|
|
)
|
|
|
for decision_id in decision_ids
|
|
|
]
|
|
|
+ direction_constraints: Sequence[Any] = ()
|
|
|
if value.task_kind in _DIRECTION_INPUT_KINDS:
|
|
|
direction = await self._active_direction_decision_ref(
|
|
|
binding=binding,
|
|
|
@@ -1053,11 +1145,18 @@ class PhaseTwoPlanningService:
|
|
|
root_trace_id=root_trace_id,
|
|
|
)
|
|
|
refs = [
|
|
|
- item
|
|
|
- for item in refs
|
|
|
- if item.expected_task_kind is not ScriptTaskKind.DIRECTION
|
|
|
+ item for item in refs if item.expected_task_kind is not ScriptTaskKind.DIRECTION
|
|
|
]
|
|
|
refs.insert(0, direction)
|
|
|
+ direction_version = await self.artifacts.get_by_id(
|
|
|
+ binding.active_direction_artifact_version_id,
|
|
|
+ script_build_id=binding.script_build_id,
|
|
|
+ )
|
|
|
+ if not isinstance(direction_version.artifact, DirectionArtifact):
|
|
|
+ raise TaskContractError(
|
|
|
+ "GOAL_SCOPE_INVALID", "active Direction artifact has the wrong type"
|
|
|
+ )
|
|
|
+ direction_constraints = direction_version.artifact.constraints
|
|
|
|
|
|
comparison_refs = tuple(
|
|
|
[
|
|
|
@@ -1070,26 +1169,40 @@ class PhaseTwoPlanningService:
|
|
|
]
|
|
|
)
|
|
|
base_ref = next(
|
|
|
- (
|
|
|
- item.artifact_ref
|
|
|
- for item in refs
|
|
|
- if item.decision_id == value.base_decision_id
|
|
|
- ),
|
|
|
+ (item.artifact_ref for item in refs if item.decision_id == value.base_decision_id),
|
|
|
None,
|
|
|
)
|
|
|
+ scope_ref = await self._resolve_scope_selector(
|
|
|
+ root_trace_id=root_trace_id,
|
|
|
+ parent=parent,
|
|
|
+ selector=value.scope_selector,
|
|
|
+ )
|
|
|
+ criteria = self.task_policy.criteria(
|
|
|
+ task_id=task_id,
|
|
|
+ task_kind=value.task_kind,
|
|
|
+ business=value.criteria,
|
|
|
+ direction_constraints=direction_constraints,
|
|
|
+ )
|
|
|
+ manifest_digest = _compiler_manifest_digest(
|
|
|
+ policy_digest=self.task_policy.manifest_digest,
|
|
|
+ task_kind=value.task_kind,
|
|
|
+ scope_ref=scope_ref,
|
|
|
+ decision_ids=tuple(item.decision_id for item in refs),
|
|
|
+ comparison_ids=tuple(item.decision_id for item in comparison_refs),
|
|
|
+ )
|
|
|
return ScriptTaskContractV1(
|
|
|
task_kind=value.task_kind,
|
|
|
- scope_ref=value.scope_ref,
|
|
|
+ scope_ref=scope_ref,
|
|
|
intent_class=value.intent_class,
|
|
|
objective=value.objective,
|
|
|
input_decision_refs=tuple(refs),
|
|
|
base_artifact_ref=base_ref,
|
|
|
write_scope=_write_scope_for(value.task_kind),
|
|
|
- gap_ref=value.gap_ref,
|
|
|
+ gap_ref=(f"script-build://gaps/{value.gap_key}" if value.gap_key else None),
|
|
|
output_schema=output_schema_for(
|
|
|
value.task_kind, patched=value.base_decision_id is not None
|
|
|
),
|
|
|
- criteria=value.criteria,
|
|
|
+ criteria=criteria,
|
|
|
budget=ScriptTaskBudget(
|
|
|
max_attempts=self.limits.max_attempts_per_task,
|
|
|
max_tokens=self.limits.max_task_tokens,
|
|
|
@@ -1100,8 +1213,26 @@ class PhaseTwoPlanningService:
|
|
|
goal_ids=value.goal_ids,
|
|
|
supersedes_decision_ids=value.supersedes_decision_ids,
|
|
|
comparison_decision_refs=comparison_refs,
|
|
|
+ compiler_manifest_digest=manifest_digest,
|
|
|
)
|
|
|
|
|
|
+ async def _resolve_scope_selector(
|
|
|
+ self, *, root_trace_id: str, parent: Any, selector: Any
|
|
|
+ ) -> str:
|
|
|
+ if selector.anchor.value == "mission":
|
|
|
+ base: tuple[str, ...] = ()
|
|
|
+ elif parent.task_id == (await self.coordinator.task_store.load(root_trace_id)).root_task_id:
|
|
|
+ base = ()
|
|
|
+ else:
|
|
|
+ frozen = await self.contract_for_task(root_trace_id, parent)
|
|
|
+ base = _scope_path(frozen.contract.scope_ref)
|
|
|
+ path = (*base, *selector.path)
|
|
|
+ if not path:
|
|
|
+ raise TaskContractError(
|
|
|
+ "TASK_CONTRACT_INVALID", "scope_selector resolves to an empty mission scope"
|
|
|
+ )
|
|
|
+ return "script-build://scopes/" + "/".join(path)
|
|
|
+
|
|
|
async def _resolve_decision_ref(
|
|
|
self,
|
|
|
*,
|
|
|
@@ -1568,9 +1699,7 @@ class PhaseTwoPlanningService:
|
|
|
if decision is not None and decision.attempt_id is not None
|
|
|
else None
|
|
|
)
|
|
|
- producer = (
|
|
|
- ledger.tasks.get(attempt.task_id) if attempt is not None else None
|
|
|
- )
|
|
|
+ producer = ledger.tasks.get(attempt.task_id) if attempt is not None else None
|
|
|
if producer is None:
|
|
|
raise TaskContractError(
|
|
|
"GOAL_SCOPE_INVALID", "Compare references an unknown candidate Task"
|
|
|
@@ -1644,6 +1773,14 @@ class PhaseTwoPlanningService:
|
|
|
raise TaskContractError(
|
|
|
"INPUT_SCOPE_MISMATCH",
|
|
|
str(exc),
|
|
|
+ details={
|
|
|
+ "decision_id": imported_ref.decision_id,
|
|
|
+ "consumer_kind": consumer.task_kind.value,
|
|
|
+ "consumer_scope": consumer.scope_ref,
|
|
|
+ "producer_kind": producer.task_kind.value,
|
|
|
+ "producer_scope": producer.scope_ref,
|
|
|
+ "source": AcceptedInputSource.EXPLICIT.value,
|
|
|
+ },
|
|
|
) from exc
|
|
|
|
|
|
async def _guard_runtime_budget(
|
|
|
@@ -1767,12 +1904,15 @@ class PhaseTwoPlanningService:
|
|
|
if action is DecisionAction.REVISE and replacement_contract is not None:
|
|
|
replacement = PlannerTaskInput.from_payload(replacement_contract)
|
|
|
old_contract = current_contract.contract
|
|
|
+ old_path = tuple(
|
|
|
+ item
|
|
|
+ for item in old_contract.scope_ref.removeprefix("script-build://scopes/").split("/")
|
|
|
+ if item
|
|
|
+ )
|
|
|
if (
|
|
|
replacement.intent_class != old_contract.intent_class
|
|
|
- or (
|
|
|
- _scope_contains(old_contract.scope_ref, replacement.scope_ref)
|
|
|
- and replacement.scope_ref != old_contract.scope_ref
|
|
|
- )
|
|
|
+ or replacement.scope_selector.anchor.value != "mission"
|
|
|
+ or replacement.scope_selector.path != old_path
|
|
|
):
|
|
|
return
|
|
|
raise TaskContractError(
|
|
|
@@ -1827,11 +1967,24 @@ class PhaseTwoPlanningService:
|
|
|
)
|
|
|
return
|
|
|
parent_contract = (await self.contract_for_task(root_trace_id, parent)).contract
|
|
|
- if any(
|
|
|
- not _scope_contains(parent_contract.scope_ref, item.scope_ref) for item in contracts
|
|
|
- ):
|
|
|
+ outside = next(
|
|
|
+ (
|
|
|
+ item
|
|
|
+ for item in contracts
|
|
|
+ if not _scope_contains(parent_contract.scope_ref, item.scope_ref)
|
|
|
+ ),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ if outside is not None:
|
|
|
raise TaskContractError(
|
|
|
- "INPUT_SCOPE_MISMATCH", "child Task scope must stay within its parent scope"
|
|
|
+ "INPUT_SCOPE_MISMATCH",
|
|
|
+ "child Task scope must stay within its parent scope",
|
|
|
+ details={
|
|
|
+ "parent_kind": parent_contract.task_kind.value,
|
|
|
+ "parent_scope": parent_contract.scope_ref,
|
|
|
+ "child_kind": outside.task_kind.value,
|
|
|
+ "child_scope": outside.scope_ref,
|
|
|
+ },
|
|
|
)
|
|
|
if parent_contract.write_scope and any(
|
|
|
any(
|
|
|
@@ -1914,6 +2067,66 @@ class PhaseTwoPlanningService:
|
|
|
"a completed consumer requires a new replacement Task for new Evidence",
|
|
|
)
|
|
|
|
|
|
+ @staticmethod
|
|
|
+ def _guard_direction_dispatch(
|
|
|
+ ledger: Any,
|
|
|
+ task: Any,
|
|
|
+ kind: ScriptTaskKind,
|
|
|
+ *,
|
|
|
+ phase: int | None,
|
|
|
+ ) -> None:
|
|
|
+ if phase != 1 or kind is not ScriptTaskKind.DIRECTION:
|
|
|
+ return
|
|
|
+ for child_id in task.child_task_ids:
|
|
|
+ child = ledger.tasks.get(child_id)
|
|
|
+ if child is None or child.status is not TaskStatus.COMPLETED:
|
|
|
+ continue
|
|
|
+ child_kind = task_kind_from_context_refs(child.current_spec.context_refs)
|
|
|
+ if child_kind not in RETRIEVAL_KINDS or not child.decision_ids:
|
|
|
+ continue
|
|
|
+ decision = ledger.decisions.get(child.decision_ids[-1])
|
|
|
+ if decision is not None and decision.action is DecisionAction.ACCEPT:
|
|
|
+ return
|
|
|
+ raise TaskContractError(
|
|
|
+ "CHILD_DECISION_INVALID",
|
|
|
+ "Phase1 Direction requires at least one direct-child Retrieval ACCEPT before dispatch",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _planned_task_id(root_trace_id: str, tool_call_id: str | None, index: int) -> str:
|
|
|
+ if tool_call_id:
|
|
|
+ return str(uuid5(NAMESPACE_URL, f"{root_trace_id}:{tool_call_id}:{index}"))
|
|
|
+ return str(uuid4())
|
|
|
+
|
|
|
+
|
|
|
+def _scope_path(scope_ref: str) -> tuple[str, ...]:
|
|
|
+ prefix = "script-build://scopes/"
|
|
|
+ if not scope_ref.startswith(prefix):
|
|
|
+ raise TaskContractError("TASK_CONTRACT_INVALID", "parent scope is not canonical")
|
|
|
+ return tuple(item for item in scope_ref.removeprefix(prefix).split("/") if item)
|
|
|
+
|
|
|
+
|
|
|
+def _compiler_manifest_digest(
|
|
|
+ *,
|
|
|
+ policy_digest: str,
|
|
|
+ task_kind: ScriptTaskKind,
|
|
|
+ scope_ref: str,
|
|
|
+ decision_ids: tuple[str, ...],
|
|
|
+ comparison_ids: tuple[str, ...],
|
|
|
+) -> str:
|
|
|
+ encoded = json.dumps(
|
|
|
+ {
|
|
|
+ "policy_digest": policy_digest,
|
|
|
+ "task_kind": task_kind.value,
|
|
|
+ "scope_ref": scope_ref,
|
|
|
+ "decision_ids": decision_ids,
|
|
|
+ "comparison_ids": comparison_ids,
|
|
|
+ },
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ ).encode()
|
|
|
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
|
+
|
|
|
|
|
|
def _required(context: Mapping[str, Any], key: str) -> str:
|
|
|
value = context.get(key)
|
|
|
@@ -1965,6 +2178,8 @@ def _write_scope_for(kind: ScriptTaskKind) -> tuple[str, ...]:
|
|
|
if kind in {ScriptTaskKind.STRUCTURE, ScriptTaskKind.PARAGRAPH}:
|
|
|
return ("script-build://writes/paragraphs",)
|
|
|
if kind is ScriptTaskKind.ELEMENT_SET:
|
|
|
+ # ElementSet owns Element rows and their placement links. It may read or
|
|
|
+ # materialize Paragraph endpoints, but it does not own Paragraph prose.
|
|
|
return ("script-build://writes/elements",)
|
|
|
if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO}:
|
|
|
return ("script-build://writes",)
|
|
|
@@ -2068,18 +2283,12 @@ def _decision_attempt_id(ledger: Any, task: Any, validation_id: str | None) -> s
|
|
|
def _root_replacement(task: Any, value: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
"""Allow a safe Root objective refinement without inventing a business contract."""
|
|
|
|
|
|
- if set(value) != {"objective", "acceptance_criteria", "context_refs"}:
|
|
|
+ if set(value) != {"objective"}:
|
|
|
raise TaskContractError(
|
|
|
"TASK_CONTRACT_INVALID",
|
|
|
- "Root replacement must contain objective, acceptance_criteria and context_refs",
|
|
|
+ "Root replacement may contain only the semantic objective",
|
|
|
)
|
|
|
objective = _bounded(str(value["objective"]), 2_000, "Root objective")
|
|
|
- criteria = value["acceptance_criteria"]
|
|
|
- refs = value["context_refs"]
|
|
|
- if not isinstance(criteria, Sequence) or isinstance(criteria, (str, bytes)):
|
|
|
- raise TaskContractError("TASK_CONTRACT_INVALID", "Root criteria must be an array")
|
|
|
- if not isinstance(refs, Sequence) or isinstance(refs, (str, bytes)):
|
|
|
- raise TaskContractError("TASK_CONTRACT_INVALID", "Root context_refs must be an array")
|
|
|
expected_criteria = [
|
|
|
{
|
|
|
"criterion_id": item.criterion_id,
|
|
|
@@ -2089,11 +2298,6 @@ def _root_replacement(task: Any, value: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
for item in task.current_spec.acceptance_criteria
|
|
|
]
|
|
|
expected_refs = list(task.current_spec.context_refs)
|
|
|
- if list(criteria) != expected_criteria or list(refs) != expected_refs:
|
|
|
- raise TaskContractError(
|
|
|
- "TASK_CONTRACT_INVALID",
|
|
|
- "Root REVISE may refine only objective and cannot expand its contract",
|
|
|
- )
|
|
|
return {
|
|
|
"objective": objective,
|
|
|
"acceptance_criteria": expected_criteria,
|